pi-pretty-tui 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,14 +6,14 @@ A beautiful, polished TUI for the [Pi coding agent](https://pi.dev/).
6
6
 
7
7
  ## Features
8
8
 
9
- - **Collapsible activity timeline:** instead of filling the conversation with individual calls, thoughts, tools, and ordinary `info` notifications share one chronological activity stream. Work appears beneath a single `Running(...)` row and settles into a quiet `Done(...)` row. Parent rows distinguish top-level tool calls, nested calls, and thoughts; expanding one reveals every ordered member with summaries retained above nested details. Thoughts and tools are peers: a turn that calls no tool still gets its own `Running(1 thought)` / `Done(1 thought)` parent row with the Thought as its child, so thinking is never dropped.
10
- - **Live working duration:** in clean mode, elapsed time appears beside `Working` in the prompt editor's top border, using the border's own color. It shares the agent-run clock and duration format with the completion footer, includes automatic retries, and disappears when the run settles. Narrow editors omit the timer when it cannot fit. Expanded `Done` titles and markers use the theme's `success` color; collapsed summaries remain quiet gray.
9
+ - **Collapsible activity timeline:** instead of filling the conversation with individual calls, thoughts, tools, and ordinary `info` notifications share one chronological activity stream. Work appears beneath a single `Running(...)` row and settles into a quiet `Done(...)` row. Parent rows show a combined total of top-level and nested tool calls, plus thoughts; expanding one reveals every ordered member with summaries retained above nested details. Thoughts and tools are peers: a turn that calls no tool still gets its own `Running(1 thought)` / `Done(1 thought)` parent row with the Thought as its child, so thinking is never dropped.
10
+ - **Live working duration:** in clean mode, elapsed time appears beside `Working` in the prompt editor's top border, using the border's own color. It shares the agent-run clock and duration format with the completion footer, includes automatic retries, and disappears when the run settles. Narrow editors omit the timer when it cannot fit. The clean-mode `Running` parent dot alternates between the prompt editor’s current border color and `dim` about every 800 ms; its label uses that same border color, and tool dots stay stable, and the pulse stops when the run settles. Expanded `Done` titles and markers use the theme's `success` color; collapsed summary dots, titles, and details use `dim`.
11
11
  - **Transient UI status:** one line above the editor shows direct Pi UI feedback such as Thinking/tool-output visibility changes. A newer UI status replaces it, and it disappears automatically after ten seconds without entering the transcript or session.
12
12
  - **Durable response footers:** once the complete agent run settles—including tools, retries, and compaction—the final answer ends with one quiet gray divider containing the elapsed-work time and, in fullscreen, a `[Copy]` control. It copies only the final visible answer, excluding thoughts, tools, statuses, and the footer itself; the footer survives reloads and session-tree navigation.
13
13
  - **Useful details on demand:** tool-aware summaries keep paths, commands, results, diffs, file previews, and live shell output concise without removing access to the original content.
14
- - **A cohesive visual finish:** transparent, right-aligned user chat bubbles size themselves to short messages and wrap longer ones at 75% of the terminal width, with an accent-blue rounded border and no redundant `User` label. Together with the rounded prompt input, theme-aware colors, clear Markdown heading hierarchy, cleaner lists, and rounded syntax-highlighted code blocks, they make the entire TUI feel intentional. In fullscreen, each fenced code block also includes a native-feeling `[Copy]` control.
14
+ - **A cohesive visual finish:** transparent, right-aligned user chat bubbles size themselves to short messages and wrap longer ones at 75% of the terminal width, with a rounded border in the theme's link color (`mdLink`) and no redundant `User` label. Together with the rounded prompt input, theme-aware colors, clear Markdown heading hierarchy, cleaner lists, and rounded syntax-highlighted code blocks, they make the entire TUI feel intentional. In fullscreen, each fenced code block also includes a native-feeling `[Copy]` control.
15
15
 
16
- Pi's built-in `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` tools receive purpose-built compact views. Third-party tools without nested calls automatically receive a consistent compact row; opening it preserves its native renderer while removing conflicting terminal background colors. User and steering messages, compaction summaries, visible assistant text, warnings, errors, aborted/truncated responses, and displayed custom messages with a registered renderer create hard group boundaries. Pi's cursor, IME, autocomplete, mouse interaction, and tool execution behavior remain intact.
16
+ Pi's built-in `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` tools receive purpose-built compact views. Third-party tools without nested calls automatically receive a consistent compact row; opening it preserves its native renderer while removing conflicting terminal background colors. User and steering messages, compaction summaries, visible assistant text, warnings, errors, aborted/truncated responses, and displayed custom messages with a registered renderer create hard group boundaries. Pi's cursor, IME, autocomplete, and tool execution behavior remain intact; fullscreen mouse additions are described below.
17
17
 
18
18
  ## Install
19
19
 
@@ -27,7 +27,7 @@ Pi may warn that built-in tools are being overridden. This is expected: pi-prett
27
27
 
28
28
  ## Working with tool calls
29
29
 
30
- Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary. The last item in each expanded tree, `└─ [↑ Collapse]`, collapses just that group without scrolling back to its parent; clicking the parent also works. Press `Ctrl+O` at any time to use Pi's global expansion control for all tool output and thinking content. The end-of-tree action still collapses only its own group after global expansion. Third-party replacements for `grep` and `find` (such as FFF) use compact tree rows too, with their native details available when expanded. Tool names use each definition's `label` verbatim, falling back to its raw `name`; pi-pretty-tui does not rename or title-case them.
30
+ Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary. The last item in each expanded tree, `└─ [↑ Collapse]`, collapses just that group without scrolling back to its parent; clicking the parent also works. In clean mode, `Ctrl+O` only shows a hint to use these local disclosures; it does not expand or collapse content. Full and compact modes retain Pi's native global tool-output expansion. Third-party replacements for `grep` and `find` (such as FFF) use compact tree rows too, with their native details available when expanded. Compact tool names use bold `text`, parameters use `muted`, and running tool dots use `dim`; success/error dots keep their corresponding theme colors. Thought dots use `mdLink`, titles use bold `text`, and detail body text uses `muted`. Tool names use each definition's `label` verbatim, falling back to its raw `name`; pi-pretty-tui does not rename or title-case them.
31
31
 
32
32
  Extension calls to `ctx.ui.notify(message, "info")` (or with the type omitted) join an existing thought/tool group in chronological order; without existing work they remain independent and never attach to a future group. Warnings and errors stay standalone, use yellow `⚠` and red `✕`, and are hard boundaries. Tool failures remain owned by their tool details. None of these extension notifications or errors appear in the transient UI-status line.
33
33
 
@@ -35,13 +35,13 @@ In clean mode, Pi's native cache-miss notices join the thought/tool group of the
35
35
 
36
36
  Displayed custom messages without their own renderer use one generic accent-coloured `◇` Markdown presentation: they join current thought/tool work when it exists, otherwise remain independent, and preserve their session persistence. Messages with a registered renderer remain completely native and form hard boundaries. These decisions use only Pi's notification type and renderer presence—never extension names, custom message names, or content heuristics.
37
37
 
38
- Pi's direct internal `showStatus()` feedback is transient: it appears only in the status line above the editor, a newer status replaces it, and a ten-second timer removes it. It never enters the transcript or activity timeline and is cleared on reload. In clean mode, `Ctrl+T` leaves Pi's persisted Thinking setting unchanged and shows a short hint instead: thought details expand only when their own row is clicked, while `Ctrl+O` retains global activity expansion. In full and compact modes, `Ctrl+T` keeps Pi's native behaviour.
38
+ Pi's direct internal `showStatus()` feedback is transient: it appears only in the status line above the editor, a newer status replaces it, and a ten-second timer removes it. It never enters the transcript or activity timeline and is cleared on reload. In clean mode, `Ctrl+T` and `Ctrl+O` only show short hints to click the corresponding disclosure; neither changes visibility or Pi's Thinking setting. In full and compact modes, both shortcuts keep Pi's native behaviour.
39
39
 
40
40
  ## Codemode and nested tools
41
41
 
42
- In clean mode, tools that call other tools through Pi's nested execution API keep their own parent node. Expand the activity group to see each internal call, its status, compact arguments, duration, and result summary. Click an internal call to inspect its arguments and text result independently. Click its expanded body to collapse just that call. Script and script-output bodies also collapse their own sections when clicked. Successful and failed tool calls consistently use a `●` marker in the theme's success and error colors, respectively. `[Script]` and `[Script output]` are separate disclosures; clicking the parent tool or pressing `Ctrl+O` expands all its details. Calls retain their start order even when they finish in parallel. A handled child failure remains marked as failed even if the script succeeds.
42
+ In clean mode, tools that call other tools through Pi's nested execution API keep their own parent node. Expand the activity group to see each internal call, its status, compact arguments, duration, and result summary. Click an internal call to inspect its arguments and text result independently. Click its expanded body to collapse just that call. Script and script-output bodies also collapse their own sections when clicked. Tool calls use a `●` marker: dim while running, success-colored when successful, and error-colored when failed. Tool names use bold theme text. `[Script]` and `[Script output]` are separate disclosures; the nested parent title is informational and clicking it does not expand or collapse details. Use each section or child to expand and collapse its own details; `Ctrl+O` only shows a hint in clean mode. Extensions can still use Pi's `setToolsExpanded()` API for global control; local disclosures remain usable afterward, and another global API call resets the local overrides. Calls retain their start order even when they finish in parallel. A handled child failure remains marked as failed even if the script succeeds.
43
43
 
44
- The group distinguishes, for example, `1 tool call · 3 nested calls`. Internal calls never become phantom transcript members or displace the end-of-tree collapse action. Ownership uses Pi's `parentToolCallId`, not tool names or JavaScript parsing; wrappers other than codemode benefit too. This requires a Pi version with nested execution events (0.99.0 or later); older Pi keeps its existing display. Full and compact modes retain the native tool presentation.
44
+ The activity-group summary combines top-level and nested calls: one parent call plus three internal calls displays as `4 tool calls`. The parent call itself counts once, while its node retains the nested-call count and tree structure. Internal calls never become phantom transcript members or displace the end-of-tree collapse action. Ownership uses Pi's `parentToolCallId`, not tool names or JavaScript parsing; wrappers other than codemode benefit too. This requires a Pi version with nested execution events (0.99.0 or later); older Pi keeps its existing display. Full and compact modes retain the native tool presentation.
45
45
 
46
46
  Child text results and edit diffs are saved in hidden, UI-only session entries for reload and branch navigation. Each root retains at most 256 calls, 4,096 characters of arguments per call, 8,192 characters of text per result, and 131,072 characters of arguments/results in total; truncation is indicated. Detail previews also have a 200-line limit, and nested trees display up to eight levels below the root. Old sessions without a snapshot explicitly show that only call metadata was retained. Nested results currently use a generic text presentation, not each third-party renderer, and images are not stored in these snapshots. Root media remains available in native full/compact mode.
47
47
 
@@ -53,8 +53,16 @@ Run `/pretty-tui` to open the extension settings. The `Enabled` switch persists
53
53
 
54
54
  The extension supports three persistent rendering modes: `clean` (default), `compact`, and `full`. Choose one interactively, or use `/pretty-tui clean`, `/pretty-tui compact`, `/pretty-tui full`, and `/pretty-tui status` directly. Settings are stored in `~/.pi/agent/pretty-tui.json` or under the directory selected by `PI_CODING_AGENT_DIR`.
55
55
 
56
+ In fullscreen, interactive activity rows and copy controls tolerate same-row horizontal pointer jitter of up to two terminal columns. Larger or vertical movement remains a drag; ordinary text selection, links, and Pi's double/triple-click behavior are unchanged.
57
+
58
+ ## Copy user messages
59
+
60
+ In fullscreen mode, left-click a user bubble's body or border to copy its complete original text, preserving Markdown, indentation, and line breaks without the bubble frame. Existing links and code-block `[Copy]` controls retain priority; dragging to select text does not trigger whole-message copy. Pi's native clipboard path provides copy feedback. This copies text, not image attachments. Regular mode keeps terminal-owned mouse selection and copying.
61
+
56
62
  ## Copy code blocks
57
63
 
64
+ Rounded code-block lines and corners use `borderMuted`; language labels, `[Copy]`, and code text retain their existing colors.
65
+
58
66
  In fullscreen TUI mode, fenced Markdown code blocks in user and assistant transcript messages include a clickable `[Copy]` control in the top rule:
59
67
 
60
68
  ```sh
@@ -76,7 +84,7 @@ The suite covers live and restored activity timelines, retries and terminal stat
76
84
 
77
85
  ## Compatibility notice
78
86
 
79
- The current development and regression-test baseline is **Pi 1.0.0**. User chat bubbles support its direct Markdown layout, keeping their transparent background and content-sized width. Nested execution requires Pi 0.99.0 or later; this is a feature requirement, not a promise that every older Pi release is tested.
87
+ The current development and regression-test baseline is **Pi 1.1.0**. User chat bubbles support its direct Markdown layout, keeping their transparent background and content-sized width. Nested execution requires Pi 0.99.0 or later; this is a feature requirement, not a promise that every older Pi release is tested.
80
88
 
81
89
  Tool rendering uses Pi's documented extension APIs. Active-tool state follows Pi's built-in execution transitions through its exported interactive components. Clean-mode thought details use the activity hierarchy's own disclosure state; full and compact modes retain Pi's built-in Thinking visibility control. Rendering native user messages as adaptive chat bubbles, rounding the native prompt editor, changing unordered-list markers, and enhancing fenced code blocks require reload-safe runtime patches because Pi does not currently expose public renderer hooks for those presentation details. Markdown enhancements are activated only while rendering user and assistant messages in the main transcript; Markdown created by extension overlays keeps Pi's native layout. Markdown parsing, syntax highlighting, and terminal semantic zones remain handled by Pi.
82
90
 
package/README.zh-CN.md CHANGED
@@ -6,14 +6,14 @@
6
6
 
7
7
  ## 功能特性
8
8
 
9
- - **可折叠的活动时间线:** 不再让大量独立调用填满对话。思考、工具和普通 `info` 通知会进入同一条按真实时间排序的活动流;工作进行时显示为 `Running(...)`,结束后变为低调的 `Done(...)`。父级分别统计顶层工具调用、嵌套调用和思考数量;展开后可查看所有有序成员,成员摘要在展开嵌套详情后仍然保留。思考与工具地位相同:没有调用任何工具的那一轮同样会有自己的 `Running(1 thought)` / `Done(1 thought)` 父行,思考作为其子级,内容不会被丢弃。
9
+ - **可折叠的活动时间线:** 不再让大量独立调用填满对话。思考、工具和普通 `info` 通知会进入同一条按真实时间排序的活动流;工作进行时显示为 `Running(...)`,结束后变为低调的 `Done(...)`。父级合并统计顶层与嵌套工具调用总数,并单独显示思考数量;展开后可查看所有有序成员,成员摘要在展开嵌套详情后仍然保留。思考与工具地位相同:没有调用任何工具的那一轮同样会有自己的 `Running(1 thought)` / `Done(1 thought)` 父行,思考作为其子级,内容不会被丢弃。
10
10
  - **可持久化的回答 footer:** 整轮 Agent 工作真正 settled 后(包括工具、自动重试和 compaction),最终回答底部会显示一条低调的灰色分隔线,工作时长与 fullscreen 模式下的 `[Copy]` 控件都嵌在线中。`[Copy]` 只复制最终可见回答,不包含 thought、工具、状态或 footer;reload 和 session tree 切换后仍会保留。
11
- - **实时工作计时:** clean 模式下,耗时显示在输入框顶部边框的 `Working` 右侧,颜色与边框一致。它与完成 footer 共用 Agent 整轮工作的计时起点和时长格式,包含自动重试,工作真正结束后消失;窄输入框放不下时会隐藏计时。展开后的 `Done` 标题和圆点使用主题的 `success` 色,折叠摘要仍保持低调的灰色。
11
+ - **实时工作计时:** clean 模式下,耗时显示在输入框顶部边框的 `Working` 右侧,颜色与边框一致。它与完成 footer 共用 Agent 整轮工作的计时起点和时长格式,包含自动重试,工作真正结束后消失;窄输入框放不下时会隐藏计时。clean 模式下,`Running` 父节点圆点约每 800 ms 在输入框当前边框色和 `dim` 间切换;`Running` 文字使用相同边框色,工具圆点保持稳定,整轮结束后停止闪烁。展开后的 `Done` 标题和圆点使用主题的 `success` 色,折叠摘要的圆点、标题与内容统一使用 `dim`。
12
12
  - **临时 UI 状态:** 编辑器上方的一行只显示 Pi 直接产生的 UI 操作反馈,例如 Thinking 或工具输出可见性变化。新状态会替换旧状态,并在十秒后自动消失,不进入 transcript 或 session。
13
13
  - **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
14
- - **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用 accent 浅蓝色圆角边框,并移除了多余的 `User` 标注。配合圆角输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
14
+ - **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用主题链接色(`mdLink`)的圆角边框,并移除了多余的 `User` 标注。配合圆角输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
15
15
 
16
- Pi 内置的 `read`、`bash`、`edit`、`write`、`grep`、`find` 和 `ls` 工具使用专门设计的紧凑视图;没有嵌套调用的第三方工具会自动获得统一的紧凑行,单独展开时继续使用原生 renderer,但会移除与整体冲突的终端背景色。用户与 steer 消息、compaction 摘要、可见 assistant 正文、warning、error、aborted/length 响应,以及注册了专用 renderer 的 displayed custom message 都会形成硬分组边界。同时不会影响 Pi 原有的光标、输入法、自动补全、鼠标交互和工具执行行为。
16
+ Pi 内置的 `read`、`bash`、`edit`、`write`、`grep`、`find` 和 `ls` 工具使用专门设计的紧凑视图;没有嵌套调用的第三方工具会自动获得统一的紧凑行,单独展开时继续使用原生 renderer,但会移除与整体冲突的终端背景色。用户与 steer 消息、compaction 摘要、可见 assistant 正文、warning、error、aborted/length 响应,以及注册了专用 renderer 的 displayed custom message 都会形成硬分组边界。同时不会影响 Pi 原有的光标、输入法、自动补全和工具执行行为;fullscreen 鼠标交互增强见下文。
17
17
 
18
18
  ## 安装
19
19
 
@@ -27,7 +27,7 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
27
27
 
28
28
  ## 使用工具调用
29
29
 
30
- 点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情。每棵展开的树末尾都有 `└─ [↑ Collapse]`,无需滚回父级即可只折叠当前分组;再次点击父级也可以折叠。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏全部工具输出和思考内容;全局展开后,树尾操作仍只折叠本组。第三方替换的 `grep`、`find`(如 FFF)也使用紧凑的树节点,展开子项时仍可查看原生详情。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
30
+ 点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情。每棵展开的树末尾都有 `└─ [↑ Collapse]`,无需滚回父级即可只折叠当前分组;再次点击父级也可以折叠。clean 模式下,`Ctrl+O` 只提示使用这些局部展开入口,不会展开或收起内容;full 和 compact 模式保留 Pi 原生的工具输出全局展开行为。第三方替换的 `grep`、`find`(如 FFF)也使用紧凑的树节点,展开子项时仍可查看原生详情。紧凑工具名使用 `text` 加粗,参数使用 `muted`,运行中工具圆点使用 `dim`,成功/失败圆点保留对应主题色。思考圆点使用 `mdLink`,标题使用 `text` 加粗,详情正文使用 `muted`。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
31
31
 
32
32
  扩展调用 `ctx.ui.notify(message, "info")`(或省略 type)时,如果当前已有 thought/tool 活动组,通知会按真实时间顺序加入该组;如果没有现有工作,则独立显示,并且不会附着到未来的工具组。warning 和 error 保持独立,分别使用黄色 `⚠` 和红色 `✕`,并形成硬边界。工具失败继续归属于对应工具详情。上述扩展通知和错误都不会进入临时 UI 状态栏。
33
33
 
@@ -35,13 +35,13 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
35
35
 
36
36
  没有自有 renderer 的 displayed custom message 使用统一、accent 配色的 `◇` Markdown 显示:当前存在 thought/tool 工作时加入当前组,否则独立显示,并保留 session 持久化。注册了 renderer 的消息完全保留插件原生显示并形成硬边界。所有判断只依据 Pi 的通知 type 和 renderer 是否存在,不识别扩展名称、custom message 名称,也不根据文本内容推断语义。
37
37
 
38
- Pi 内部直接调用 `showStatus()` 产生的反馈属于临时 UI 状态:它只显示在编辑器上方,新状态替换旧状态,并由十秒 timer 自动清除;它不会进入 transcript 或活动时间线,reload 时也会清除。在 clean 模式下,`Ctrl+T` 不修改 Pi 持久化的 Thinking 设置,只显示一条简短提示;thought 详情仅在点击对应子项时展开,`Ctrl+O` 继续负责全局活动展开。在 full 和 compact 模式下,`Ctrl+T` 保留 Pi 的原生行为。
38
+ Pi 内部直接调用 `showStatus()` 产生的反馈属于临时 UI 状态:它只显示在编辑器上方,新状态替换旧状态,并由十秒 timer 自动清除;它不会进入 transcript 或活动时间线,reload 时也会清除。在 clean 模式下,`Ctrl+T` 和 `Ctrl+O` 都只显示点击对应节点的简短提示,不改变内容可见性或 Pi 的 Thinking 设置。在 full 和 compact 模式下,两者保留 Pi 的原生行为。
39
39
 
40
40
  ## Codemode 与嵌套工具
41
41
 
42
- clean 模式下,通过 Pi 嵌套执行 API 调用其他工具的工具保留自己的父节点。展开活动组后,会显示各内部调用的状态、紧凑参数、耗时和结果摘要;点击某个内部调用,可以独立展开其参数和文本结果;点击展开后的正文可收起该调用,脚本和脚本输出正文也可点击收起各自区块。成功与失败的工具统一使用主题成功色与错误色的 `●` 圆点。`[Script]` 和 `[Script output]` 分别控制脚本与汇总输出;点击父工具或按 `Ctrl+O` 会展开全部详情。并行调用按照启动顺序排列,不会随完成顺序跳动。即使脚本捕获错误后成功,失败的子调用仍会明确标记。
42
+ clean 模式下,通过 Pi 嵌套执行 API 调用其他工具的工具保留自己的父节点。展开活动组后,会显示各内部调用的状态、紧凑参数、耗时和结果摘要;点击某个内部调用,可以独立展开其参数和文本结果;点击展开后的正文可收起该调用,脚本和脚本输出正文也可点击收起各自区块。工具统一使用 `●` 圆点:运行中为主题暗色,成功与失败分别为主题成功色与错误色;工具名使用正文色加粗。`[Script]` 和 `[Script output]` 分别控制脚本与汇总输出;嵌套父工具标题仅用于展示,点击不会展开或折叠详情。各区块或子调用通过自己的入口独立展开/收起,`Ctrl+O` 在 clean 模式下只显示提示。扩展仍可通过 Pi 的 `setToolsExpanded()` API 全局控制,之后也可局部展开/收起;再次调用全局 API 会重置局部状态。并行调用按照启动顺序排列,不会随完成顺序跳动。即使脚本捕获错误后成功,失败的子调用仍会明确标记。
43
43
 
44
- 活动组会分别统计,例如 `1 tool call · 3 nested calls`。内部调用不会变成不存在的顶层 transcript 成员,也不会挤掉树尾折叠按钮。归属依据 Pi 的 `parentToolCallId`,不依赖工具名或 JavaScript 源码解析,因此其他嵌套调用工具也适用。这需要支持嵌套执行事件的 Pi(0.99.0 及以后);旧版本保持原有显示。full 和 compact 模式保留原生工具展示。
44
+ 活动组摘要合并统计顶层与嵌套调用:一个父调用加三个内部调用显示为 `4 tool calls`,父调用本身也计一次;父工具节点仍保留子调用数量和树结构。内部调用不会变成不存在的顶层 transcript 成员,也不会挤掉树尾折叠按钮。归属依据 Pi 的 `parentToolCallId`,不依赖工具名或 JavaScript 源码解析,因此其他嵌套调用工具也适用。这需要支持嵌套执行事件的 Pi(0.99.0 及以后);旧版本保持原有显示。full 和 compact 模式保留原生工具展示。
45
45
 
46
46
  子工具的文本结果和 edit diff 会存入隐藏的、仅用于 UI 的 session 条目,支持 reload 和会话分支恢复。每个父调用最多保留 256 个子调用,每项参数最多 4,096 字符,每项文本结果最多 8,192 字符,参数和结果合计最多 131,072 字符;截断会明确提示。每项展开预览还设有 200 行上限,嵌套树最多展示父调用以下八层。没有快照的旧会话会明确显示仅保留调用记录。目前子结果使用通用文本视图,不调用各第三方工具的 renderer,也不在快照中保存图片;父工具自身的媒体结果仍可在原生 full/compact 模式查看。
47
47
 
@@ -53,8 +53,16 @@ clean 模式下,通过 Pi 嵌套执行 API 调用其他工具的工具保留
53
53
 
54
54
  扩展支持三种可持久化的渲染模式:`clean`(默认)、`compact` 和 `full`。可交互选择,也可以直接使用 `/pretty-tui clean`、`/pretty-tui compact`、`/pretty-tui full` 和 `/pretty-tui status`。设置会保存到 `~/.pi/agent/pretty-tui.json`,或 `PI_CODING_AGENT_DIR` 指定目录中的对应位置。
55
55
 
56
+ fullscreen 模式下,活动树交互行与复制入口允许同一行左右最多两个终端字符格的鼠标抖动;更大偏移或跨行移动仍按拖动处理。普通文本选择、链接和 Pi 的双击/三击行为保持不变。
57
+
58
+ ## 复制用户消息
59
+
60
+ fullscreen 模式下,左键点击用户气泡的正文或边框,可复制完整原始文本,保留 Markdown、缩进和换行,不包含气泡边框。已有链接和代码块 `[Copy]` 控件保留优先级;拖动选择文本不会触发整条复制。复制使用 Pi 原生剪贴板路径及反馈,只复制文本,不复制图片附件。regular 模式保留终端自身的鼠标选择与复制行为。
61
+
56
62
  ## 复制代码块
57
63
 
64
+ 代码块的圆角与线条使用 `borderMuted`,语言标签、`[Copy]` 和代码正文保持原有配色。
65
+
58
66
  在 fullscreen TUI 模式下,用户消息与助手回复中的 Markdown 围栏代码块会在顶部边框显示可点击的 `[Copy]` 控件:
59
67
 
60
68
  ```sh
@@ -76,7 +84,7 @@ npm test
76
84
 
77
85
  ## 兼容性说明
78
86
 
79
- 当前开发与回归测试基线为 **Pi 1.0.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
87
+ 当前开发与回归测试基线为 **Pi 1.1.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
80
88
 
81
89
  工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
82
90
 
@@ -27,6 +27,7 @@ import {
27
27
  type TuiMouseEvent,
28
28
  visibleWidth,
29
29
  wrapTextWithAnsi,
30
+ getOsc8LinkAtColumn,
30
31
  } from "@earendil-works/pi-tui";
31
32
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
33
  import { join } from "node:path";
@@ -96,7 +97,13 @@ export default function prettyTui(pi: ExtensionAPI) {
96
97
  let cleanToolsExpanded = false;
97
98
  let cleanContextCompacted = false;
98
99
  let fullscreenTui = false;
100
+ const mouseClickHitTests = new WeakMap<object, (event: any) => boolean>();
99
101
  let currentTui: any;
102
+ let currentPromptEditor: any;
103
+ const runningColor = (theme: any, text: string): string =>
104
+ typeof currentPromptEditor?.borderColor === "function"
105
+ ? currentPromptEditor.borderColor(text)
106
+ : theme.fg("border", text);
100
107
  let currentExtensionUi: any;
101
108
  let changingAllToolsExpansion = false;
102
109
  const latestActivityWidgetKey = "pretty-tui-latest-activity";
@@ -193,6 +200,7 @@ export default function prettyTui(pi: ExtensionAPI) {
193
200
  };
194
201
  const responseAnswerTexts = new Map<string, string>();
195
202
  let responseRunStartedAt: number | undefined;
203
+ const RUNNING_DOT_INTERVAL_MS = 800;
196
204
  let workingElapsedTimer: ReturnType<typeof setInterval> | undefined;
197
205
  const stopWorkingElapsedTimer = () => {
198
206
  if (workingElapsedTimer) clearInterval(workingElapsedTimer);
@@ -200,7 +208,8 @@ export default function prettyTui(pi: ExtensionAPI) {
200
208
  };
201
209
  const startWorkingElapsedTimer = () => {
202
210
  if (workingElapsedTimer) return;
203
- workingElapsedTimer = setInterval(() => currentTui?.requestRender?.(), 1000);
211
+ // Share one redraw clock between the elapsed label and Running-dot pulse.
212
+ workingElapsedTimer = setInterval(() => currentTui?.requestRender?.(), RUNNING_DOT_INTERVAL_MS);
204
213
  workingElapsedTimer.unref?.();
205
214
  };
206
215
  let responseHasAssistantMessage = false;
@@ -332,7 +341,7 @@ export default function prettyTui(pi: ExtensionAPI) {
332
341
  if (color === "error") return `\x1b[31m${text}\x1b[39m`;
333
342
  if (color === "success") return `\x1b[32m${text}\x1b[39m`;
334
343
  if (color === "syntaxComment") return markdownTheme.codeBlock(text);
335
- if (color === "thinkingLow") return markdownTheme.link(text);
344
+ if (color === "thinkingLow" || color === "mdLink") return markdownTheme.link(text);
336
345
  if (color === "dim" || color === "muted" || color === "thinkingText") {
337
346
  return markdownTheme.quote(text);
338
347
  }
@@ -416,6 +425,10 @@ export default function prettyTui(pi: ExtensionAPI) {
416
425
  }
417
426
  }
418
427
  };
428
+ const requestActivityRender = (group?: ActivityGroup): void => {
429
+ if (group) refreshActivityGroup(group);
430
+ currentTui?.requestRender?.();
431
+ };
419
432
  const revealActivityGroup = (group: ActivityGroup, ui?: any): void => {
420
433
  collapsedActivityGroups.delete(group.id);
421
434
  revealedActivityGroups.add(group.id);
@@ -718,6 +731,7 @@ export default function prettyTui(pi: ExtensionAPI) {
718
731
  const originalEditorHandleMouse = customEditorPrototype.handleMouse;
719
732
  const patchedEditorTopBorder = function (this: any, width: number, hiddenLineCount: number): string {
720
733
  if (this.tui) currentTui = this.tui;
734
+ currentPromptEditor = this;
721
735
  const indicator = this.workingStatusIndicator;
722
736
  if (renderMode !== "clean" || indicator?.kind !== "working" || responseRunStartedAt === undefined) {
723
737
  return originalEditorTopBorder.call(this, width, hiddenLineCount);
@@ -739,6 +753,7 @@ export default function prettyTui(pi: ExtensionAPI) {
739
753
  }
740
754
  };
741
755
  const patchedEditorRender = function (this: any, width: number): string[] {
756
+ currentPromptEditor = this;
742
757
  if (width < 3) return originalEditorRender.call(this, width);
743
758
 
744
759
  const innerWidth = width - 2;
@@ -782,6 +797,7 @@ export default function prettyTui(pi: ExtensionAPI) {
782
797
  customEditorPrototype.handleMouse = patchedEditorHandleMouse;
783
798
 
784
799
  pi.on("session_shutdown", () => {
800
+ currentPromptEditor = undefined;
785
801
  const patch = customEditorPrototype[editorFramePatchKey];
786
802
  if (!patch) return;
787
803
  if (patch.patchedRender === customEditorPrototype.render) {
@@ -888,8 +904,9 @@ export default function prettyTui(pi: ExtensionAPI) {
888
904
  };
889
905
  };
890
906
  const border = (text: string) => currentExtensionUi?.theme?.fg
891
- ? currentExtensionUi.theme.fg("accent", text)
892
- : markdownTheme.quoteBorder(text);
907
+ ? currentExtensionUi.theme.fg("mdLink", text)
908
+ : markdownTheme.link(text);
909
+ const userText = typeof this.text === "string" ? this.text : "";
893
910
  const frame: Component = {
894
911
  render(width: number): string[] {
895
912
  const measured = layout(width);
@@ -908,37 +925,51 @@ export default function prettyTui(pi: ExtensionAPI) {
908
925
  return [top, ...body, bottom].map((line: string) => left + line + right);
909
926
  },
910
927
  handleMouse(event: any) {
911
- if (!markdown.handleMouse) return undefined;
912
928
  const measured = layout(event.width);
913
- if (!measured.framed) {
914
- return withTranscriptMarkdown(() => markdown.handleMouse({
929
+ const height = measured.body.length + (measured.framed ? 2 : 0);
930
+ if (
931
+ event.x < measured.leftPad ||
932
+ event.x >= measured.leftPad + measured.frameWidth ||
933
+ event.y < 0 || event.y >= height
934
+ ) return undefined;
935
+
936
+ // Preserve code-block Copy controls before the enclosing bubble action.
937
+ const contentX = measured.leftPad + (measured.framed ? 2 : 0);
938
+ const contentY = measured.framed ? 1 : 0;
939
+ let result;
940
+ if (
941
+ markdown.handleMouse &&
942
+ event.x >= contentX && event.x < contentX + measured.contentWidth &&
943
+ event.y >= contentY && event.y < contentY + measured.body.length
944
+ ) {
945
+ result = withTranscriptMarkdown(() => markdown.handleMouse({
915
946
  ...event,
916
- x: event.x - measured.leftPad,
947
+ x: event.x - contentX,
948
+ y: event.y - contentY,
917
949
  width: measured.contentWidth,
950
+ height: measured.body.length,
918
951
  }));
952
+ if (result?.handled) return result;
919
953
  }
920
- const contentX = measured.leftPad + 2;
921
- if (
922
- event.y <= 0 ||
923
- event.y > measured.body.length ||
924
- event.x < contentX ||
925
- event.x >= contentX + measured.contentWidth
926
- ) {
927
- return undefined;
954
+ if (fullscreenTui && event.type === "click" && event.button === "left" && userText.trim()) {
955
+ copyTranscriptText(userText);
956
+ return { handled: true };
928
957
  }
929
- return withTranscriptMarkdown(() => markdown.handleMouse({
930
- ...event,
931
- x: event.x - contentX,
932
- y: event.y - 1,
933
- width: measured.contentWidth,
934
- height: measured.body.length,
935
- }));
958
+ return result;
936
959
  },
937
960
  invalidate() {
938
961
  markdown.invalidate?.();
939
962
  },
940
963
  };
941
964
 
965
+ const bubbleClickHitTest = (event: any) => {
966
+ if (!fullscreenTui || !userText.trim()) return false;
967
+ const measured = layout(event.width);
968
+ return event.x >= measured.leftPad && event.x < measured.leftPad + measured.frameWidth &&
969
+ event.y >= 0 && event.y < measured.body.length + (measured.framed ? 2 : 0);
970
+ };
971
+ mouseClickHitTests.set(frame, bubbleClickHitTest);
972
+ mouseClickHitTests.set(this, bubbleClickHitTest);
942
973
  this.clear();
943
974
  this.addChild(frame);
944
975
  };
@@ -1150,7 +1181,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1150
1181
  text,
1151
1182
  theme,
1152
1183
  component: new Markdown(text, 0, 0, owner.markdownTheme, {
1153
- color: (line: string) => theme.fg("thinkingText", line),
1184
+ color: (line: string) => theme.fg("muted", line),
1154
1185
  italic: true,
1155
1186
  }),
1156
1187
  };
@@ -1235,11 +1266,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1235
1266
  const terminalLines = terminalMember && visibleLines.length === 0
1236
1267
  ? renderPendingActivityUpdate(terminalMember, width)
1237
1268
  : [];
1238
- const appendTail = (activityLines: string[]) => [
1239
- ...activityLines,
1240
- ...terminalLines,
1241
- ...visibleLines,
1242
- ];
1269
+ const appendTail = (activityLines: string[]) => {
1270
+ mouseClickHitTests.set(this, (event) => renderMode === "clean" &&
1271
+ event.y >= (position.first ? 1 : 0) && event.y < activityLines.length);
1272
+ return [
1273
+ ...activityLines,
1274
+ ...terminalLines,
1275
+ ...visibleLines,
1276
+ ];
1277
+ };
1243
1278
  const revealed = activityGroupRevealed(group);
1244
1279
  if (!revealed) {
1245
1280
  const activityLines = position.first
@@ -1282,8 +1317,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1282
1317
  }
1283
1318
  const stateLabel = this.isStreaming ? "thinking" : "thought";
1284
1319
  const label = truncateToWidth(stateLabel, Math.max(1, childWidth - 2), "…");
1285
- const header = groupTheme.fg("thinkingLow", "● ") +
1286
- groupTheme.fg("toolTitle", groupTheme.bold(label));
1320
+ const header = groupTheme.fg("mdLink", "● ") +
1321
+ groupTheme.fg("text", groupTheme.bold(label));
1287
1322
  let contentLines: string[] = [header];
1288
1323
  if (expanded) {
1289
1324
  contentLines.push(...thinkingDetailLines(
@@ -1486,6 +1521,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1486
1521
  const toolsExpansionPatchKey = Symbol.for("pretty-tui.clean-tool-expansion");
1487
1522
  if (!interactiveModePrototype[toolsExpansionPatchKey]) {
1488
1523
  const originalSetToolsExpanded = interactiveModePrototype.setToolsExpanded;
1524
+ const originalToggleToolOutputExpansion = interactiveModePrototype.toggleToolOutputExpansion;
1489
1525
  const originalRenderSessionEntries = interactiveModePrototype.renderSessionEntries;
1490
1526
  const originalSwitchTuiMode = interactiveModePrototype.switchTuiMode;
1491
1527
  const originalShowExtensionNotify = interactiveModePrototype.showExtensionNotify;
@@ -1504,8 +1540,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1504
1540
  // carry no `this`, so deferred notices need this handle to mount.
1505
1541
  const cacheNoticeHost: { current?: any } = {};
1506
1542
  const cacheNoticeOwners = new WeakMap<object, any>();
1543
+ const patchedToggleToolOutputExpansion = function (this: any) {
1544
+ if (renderMode !== "clean") {
1545
+ return originalToggleToolOutputExpansion.call(this);
1546
+ }
1547
+ this.showStatus(
1548
+ "Activity details expand individually in clean mode\nClick a group, tool, thought, or section to expand/collapse it.",
1549
+ );
1550
+ };
1507
1551
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1508
1552
  cleanToolsExpanded = expanded;
1553
+ nestedTools.clearExpansion();
1509
1554
  collapsedActivityGroups.clear();
1510
1555
  changingAllToolsExpansion = true;
1511
1556
  if (!expanded) {
@@ -1596,9 +1641,9 @@ export default function prettyTui(pi: ExtensionAPI) {
1596
1641
  }
1597
1642
  // Do not change Pi's persisted hideThinkingBlock setting in clean mode.
1598
1643
  // Thought details use the activity hierarchy instead: click one thought,
1599
- // or use Ctrl+O when every member should expand together.
1644
+ // Keyboard visibility toggles only explain these local controls.
1600
1645
  this.showStatus(
1601
- "Thought details expand individually in clean mode\nClick a thought, or use Ctrl+O to expand all.",
1646
+ "Thought details expand individually in clean mode\nClick a thought to expand/collapse its details.",
1602
1647
  );
1603
1648
  };
1604
1649
 
@@ -1785,6 +1830,8 @@ export default function prettyTui(pi: ExtensionAPI) {
1785
1830
  interactiveModePrototype[toolsExpansionPatchKey] = {
1786
1831
  originalSetToolsExpanded,
1787
1832
  patchedSetToolsExpanded,
1833
+ originalToggleToolOutputExpansion,
1834
+ patchedToggleToolOutputExpansion,
1788
1835
  originalRenderSessionEntries,
1789
1836
  patchedRenderSessionEntries,
1790
1837
  originalSwitchTuiMode,
@@ -1807,6 +1854,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1807
1854
  patchedAddMessageToChat,
1808
1855
  };
1809
1856
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
1857
+ interactiveModePrototype.toggleToolOutputExpansion = patchedToggleToolOutputExpansion;
1810
1858
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
1811
1859
  interactiveModePrototype.switchTuiMode = patchedSwitchTuiMode;
1812
1860
  interactiveModePrototype.showExtensionNotify = patchedShowExtensionNotify;
@@ -1831,6 +1879,9 @@ export default function prettyTui(pi: ExtensionAPI) {
1831
1879
  currentExtensionUi = undefined;
1832
1880
  const patch = interactiveModePrototype[toolsExpansionPatchKey];
1833
1881
  if (!patch) return;
1882
+ if (patch.patchedToggleToolOutputExpansion === interactiveModePrototype.toggleToolOutputExpansion) {
1883
+ interactiveModePrototype.toggleToolOutputExpansion = patch.originalToggleToolOutputExpansion;
1884
+ }
1834
1885
  if (patch.patchedSetToolsExpanded === interactiveModePrototype.setToolsExpanded) {
1835
1886
  interactiveModePrototype.setToolsExpanded = patch.originalSetToolsExpanded;
1836
1887
  }
@@ -1866,6 +1917,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1866
1917
  }
1867
1918
  if (
1868
1919
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
1920
+ interactiveModePrototype.toggleToolOutputExpansion === patch.originalToggleToolOutputExpansion &&
1869
1921
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
1870
1922
  interactiveModePrototype.switchTuiMode === patch.originalSwitchTuiMode &&
1871
1923
  interactiveModePrototype.showExtensionNotify === patch.originalShowExtensionNotify &&
@@ -1882,24 +1934,70 @@ export default function prettyTui(pi: ExtensionAPI) {
1882
1934
  });
1883
1935
  }
1884
1936
 
1885
- // Fullscreen selection only emits a component click when press and release
1886
- // land on the exact same cell. Give clean group rows a tiny horizontal
1887
- // tolerance so an ordinary click does not turn into an accidental selection.
1937
+ // Allow same-row horizontal jitter only on our interactive regions. Keep
1938
+ // real drags, links, ordinary transcript selection, and multi-click rules native.
1888
1939
  const altScreenPrototype = TuiAltScreen.prototype as any;
1889
1940
  const cleanSummaryClickPatchKey = Symbol.for("pretty-tui.clean-summary-click");
1890
1941
  const cleanSummaryPressKey = Symbol("pretty-tui.clean-summary-press");
1942
+ const cleanControlPressKey = Symbol("pretty-tui.control-press");
1891
1943
  if (!altScreenPrototype[cleanSummaryClickPatchKey]) {
1892
1944
  const originalHandleSelectionMouseEvent = altScreenPrototype.handleSelectionMouseEvent;
1945
+ const originalHandleMouseEvent = altScreenPrototype.handleMouseEvent;
1893
1946
  const summaryAtPoint = (screen: any, x: number, y: number): boolean => {
1894
1947
  const line = stripTerminalSequences(screen.previousScreen?.[y] ?? "");
1895
1948
  const match = /●\s+(?:Done|Running)\([^)]*\)/.exec(line);
1896
- return Boolean(match && x >= match.index && x < match.index + match[0].length);
1949
+ if (match) {
1950
+ const start = visibleWidth(line.slice(0, match.index));
1951
+ if (x >= start && x < start + visibleWidth(match[0])) return true;
1952
+ }
1953
+ const collapse = line.indexOf(collapseLabel);
1954
+ const start = visibleWidth(line.slice(0, collapse));
1955
+ return renderMode === "clean" && collapse >= 0 && x >= start && x < start + visibleWidth(collapseLabel);
1956
+ };
1957
+ const interactiveAtPoint = (screen: any, x: number, y: number): boolean => {
1958
+ if (screen.hasOverlay?.()) return false;
1959
+ if (getOsc8LinkAtColumn(screen.previousScreen?.[y] ?? "", x)) return false;
1960
+ const visit = (box: any): boolean => {
1961
+ if (!box?.rect || !box?.clip || x < box.clip.x || y < box.clip.y ||
1962
+ x >= box.clip.x + box.clip.width || y >= box.clip.y + box.clip.height) return false;
1963
+ if ((box.children ?? []).some(visit)) return true;
1964
+ const hitTest = mouseClickHitTests.get(box.component);
1965
+ return Boolean(hitTest?.({
1966
+ x: x - box.rect.x, y: y - box.rect.y,
1967
+ width: box.rect.width, height: box.rect.height,
1968
+ }));
1969
+ };
1970
+ return visit(screen.currentLayout?.root) || summaryAtPoint(screen, x, y);
1971
+ };
1972
+ const withinTolerance = (press: any, event: any): boolean =>
1973
+ event.y === press.y && Math.abs(event.x - press.x) <= 2;
1974
+ const isUnmodifiedLeftPress = (event: any): boolean =>
1975
+ !event.release && (event.button & (32 | 64 | 4 | 8 | 16)) === 0 && (event.button & 3) === 0;
1976
+ // Copy controls can consume press, bypassing the selection handler entirely.
1977
+ const patchedHandleMouseEvent = function (this: any, event: any) {
1978
+ if (isUnmodifiedLeftPress(event)) {
1979
+ if (interactiveAtPoint(this, event.x, event.y)) this[cleanControlPressKey] = { x: event.x, y: event.y };
1980
+ else delete this[cleanControlPressKey];
1981
+ } else {
1982
+ const press = this[cleanControlPressKey];
1983
+ if (press && this.mousePressTarget && !this.mouseCapture) {
1984
+ if ((event.button & 32) !== 0 && withinTolerance(press, event)) return;
1985
+ if (event.release) {
1986
+ delete this[cleanControlPressKey];
1987
+ if (withinTolerance(press, event)) {
1988
+ return originalHandleMouseEvent.call(this, { ...event, x: press.x });
1989
+ }
1990
+ } else delete this[cleanControlPressKey];
1991
+ }
1992
+ if (event.release || (event.button & 64) !== 0) delete this[cleanControlPressKey];
1993
+ }
1994
+ return originalHandleMouseEvent.call(this, event);
1897
1995
  };
1898
1996
  const patchedHandleSelectionMouseEvent = function (this: any, event: any) {
1899
1997
  const isMotion = (event.button & 32) !== 0;
1900
- const isLeftPress = !event.release && !isMotion && (event.button & 3) === 0;
1998
+ const isLeftPress = isUnmodifiedLeftPress(event);
1901
1999
  if (isLeftPress) {
1902
- if (summaryAtPoint(this, event.x, event.y)) {
2000
+ if (interactiveAtPoint(this, event.x, event.y)) {
1903
2001
  this[cleanSummaryPressKey] = { x: event.x, y: event.y };
1904
2002
  } else {
1905
2003
  delete this[cleanSummaryPressKey];
@@ -1909,8 +2007,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1909
2007
 
1910
2008
  const press = this[cleanSummaryPressKey];
1911
2009
  if (press) {
1912
- const withinClickTolerance =
1913
- event.y === press.y && Math.abs(event.x - press.x) <= 2;
2010
+ const withinClickTolerance = withinTolerance(press, event);
1914
2011
  if (isMotion && withinClickTolerance) {
1915
2012
  return;
1916
2013
  }
@@ -1929,17 +2026,24 @@ export default function prettyTui(pi: ExtensionAPI) {
1929
2026
  altScreenPrototype[cleanSummaryClickPatchKey] = {
1930
2027
  originalHandleSelectionMouseEvent,
1931
2028
  patchedHandleSelectionMouseEvent,
2029
+ originalHandleMouseEvent,
2030
+ patchedHandleMouseEvent,
1932
2031
  };
1933
2032
  altScreenPrototype.handleSelectionMouseEvent = patchedHandleSelectionMouseEvent;
2033
+ altScreenPrototype.handleMouseEvent = patchedHandleMouseEvent;
1934
2034
 
1935
2035
  pi.on("session_shutdown", () => {
1936
2036
  const patch = altScreenPrototype[cleanSummaryClickPatchKey];
2037
+ if (patch?.patchedHandleMouseEvent === altScreenPrototype.handleMouseEvent) {
2038
+ altScreenPrototype.handleMouseEvent = patch.originalHandleMouseEvent;
2039
+ }
1937
2040
  if (patch?.patchedHandleSelectionMouseEvent === altScreenPrototype.handleSelectionMouseEvent) {
1938
2041
  altScreenPrototype.handleSelectionMouseEvent = patch.originalHandleSelectionMouseEvent;
1939
2042
  }
1940
2043
  if (
1941
2044
  patch &&
1942
- altScreenPrototype.handleSelectionMouseEvent === patch.originalHandleSelectionMouseEvent
2045
+ altScreenPrototype.handleSelectionMouseEvent === patch.originalHandleSelectionMouseEvent &&
2046
+ altScreenPrototype.handleMouseEvent === patch.originalHandleMouseEvent
1943
2047
  ) {
1944
2048
  delete altScreenPrototype[cleanSummaryClickPatchKey];
1945
2049
  }
@@ -1968,6 +2072,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1968
2072
 
1969
2073
  const patchedRender = function (this: any, width: number): string[] {
1970
2074
  const scoped = transcriptMarkdownActive();
2075
+ mouseClickHitTests.set(this, (event) => fullscreenTui &&
2076
+ this[transcriptMarkdownStateKey] === true &&
2077
+ (this[codeBlockRegionsKey] ?? []).some((region: any) =>
2078
+ event.y === region.y && event.x >= region.xStart && event.x < region.xEnd));
1971
2079
  if (this[transcriptMarkdownStateKey] !== scoped) {
1972
2080
  delete this[codeBlockCollectionKey];
1973
2081
  delete this[codeBlockRegionsKey];
@@ -2158,17 +2266,23 @@ export default function prettyTui(pi: ExtensionAPI) {
2158
2266
  );
2159
2267
  const buttonStart = visibleWidth(topPrefix) + fillWidth + (showCopyButton ? 1 : 0);
2160
2268
  const topRule = topPrefix + "─".repeat(fillWidth) + topSuffix;
2161
- const styledTopRule = this.theme.codeBlockBorder(topRule);
2269
+ const frameBorder = (text: string) => currentExtensionUi?.theme?.fg
2270
+ ? currentExtensionUi.theme.fg("borderMuted", text)
2271
+ : this.theme.codeBlockBorder(text);
2272
+ // Keep labels and Copy at their original contrast; soften only the frame.
2273
+ const styledTopRule = frameBorder("╭─ ") + this.theme.codeBlockBorder(label + " ") +
2274
+ frameBorder("─".repeat(fillWidth)) +
2275
+ (showCopyButton ? this.theme.codeBlockBorder(` ${copyLabel} `) : "") + frameBorder("╮");
2162
2276
  const framedCodeWidth = Math.max(1, frameWidth - 4);
2163
2277
  const lines = [
2164
2278
  styledTopRule,
2165
2279
  ...codeLines.map((line) =>
2166
- this.theme.codeBlockBorder("│ ") +
2280
+ frameBorder("│ ") +
2167
2281
  line +
2168
2282
  " ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) +
2169
- this.theme.codeBlockBorder(" │")
2283
+ frameBorder(" │")
2170
2284
  ),
2171
- this.theme.codeBlockBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
2285
+ frameBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
2172
2286
  ];
2173
2287
 
2174
2288
  if (showCopyButton && Array.isArray(this[codeBlockCollectionKey])) {
@@ -2336,9 +2450,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2336
2450
  },
2337
2451
  continuation: " ",
2338
2452
  content:
2339
- theme.fg("accent", theme.bold(name)) +
2453
+ theme.fg("text", theme.bold(name)) +
2340
2454
  theme.fg("dim", "(") +
2341
- theme.fg("text", detail) +
2455
+ theme.fg("muted", detail) +
2342
2456
  theme.fg("dim", ")"),
2343
2457
  });
2344
2458
 
@@ -2604,9 +2718,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2604
2718
  ? "error"
2605
2719
  : component.result && !component.isPartial
2606
2720
  ? "success"
2607
- : "accent";
2721
+ : "dim";
2608
2722
  const title = theme.fg(dotColor, "● ") +
2609
- theme.fg("accent", theme.bold(label)) +
2723
+ theme.fg("text", theme.bold(label)) +
2610
2724
  (args ? theme.fg("muted", `(${args})`) : "");
2611
2725
  const result = thirdPartyResultSummary(component);
2612
2726
  const resultColor = component.result?.isError ? "error" : "muted";
@@ -2775,10 +2889,27 @@ export default function prettyTui(pi: ExtensionAPI) {
2775
2889
  const group = activityTimeline.groupForMember(member.id);
2776
2890
  if (renderMode !== "clean" || !group) return originalToolRender.call(this, width);
2777
2891
  const position = activityMemberPosition(group, member);
2892
+ let renderedHeight = 0;
2893
+ let nestedClickActions: Map<number, string> | undefined;
2894
+ const renderedSummaryHeight = position.first ? renderActivityGroupSummary(group, width).length : 0;
2895
+ mouseClickHitTests.set(this, (event) => {
2896
+ if (renderMode !== "clean") return false;
2897
+ if (event.width !== width) return false;
2898
+ const summaryHeight = renderedSummaryHeight;
2899
+ if (position.first && event.y > 0 && event.y <= summaryHeight) return true;
2900
+ if (!activityGroupRevealed(group)) return false;
2901
+ const height = renderedHeight;
2902
+ if (position.last && event.y === height - 1) {
2903
+ return activityCollapseClicked(group, member, { ...event, type: "click", button: "left" }, height - 1);
2904
+ }
2905
+ const contentY = event.y - (position.first ? summaryHeight + 1 : 0);
2906
+ if (nestedClickActions) return nestedClickActions.has(contentY);
2907
+ return contentY >= 0 && event.y < height - (position.last ? 1 : 0);
2908
+ });
2778
2909
  if (!activityGroupRevealed(group)) {
2779
- return position.first
2780
- ? ["", ...renderActivityGroupSummary(group, width, true)]
2781
- : [];
2910
+ const lines = position.first ? ["", ...renderActivityGroupSummary(group, width, true)] : [];
2911
+ renderedHeight = lines.length;
2912
+ return lines;
2782
2913
  }
2783
2914
 
2784
2915
  const childTheme = cleanThemeForToolCall(this.toolCallId) ?? activityGroupTheme(group);
@@ -2810,7 +2941,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2810
2941
  } else {
2811
2942
  let contentLines: string[];
2812
2943
  if (nestedTools.count(this.toolCallId) > 0) {
2813
- contentLines = nestedTools.render(this, childWidth, childTheme).lines;
2944
+ const projection = nestedTools.render(this, childWidth, childTheme);
2945
+ contentLines = projection.lines;
2946
+ nestedClickActions = projection.actions;
2814
2947
  } else if (thirdParty) {
2815
2948
  const summaryLines = renderThirdPartyCompact(this, childWidth, childTheme);
2816
2949
  if (this.expanded) {
@@ -2859,10 +2992,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2859
2992
  };
2860
2993
  }
2861
2994
  }
2862
- return withActivityCollapse(
2995
+ const lines = withActivityCollapse(
2863
2996
  group, member, width,
2864
2997
  position.first ? ["", ...renderActivityGroupSummary(group, width), ...decoratedContent] : decoratedContent,
2865
2998
  );
2999
+ renderedHeight = lines.length;
3000
+ return lines;
2866
3001
  };
2867
3002
  const patchedToolHandleMouse = function (this: any, event: any) {
2868
3003
  const member = activityTimeline.memberForTool(this.toolCallId);
@@ -2902,13 +3037,11 @@ export default function prettyTui(pi: ExtensionAPI) {
2902
3037
  const contentY = event.y - (position.first ? summaryHeight + 1 : 0);
2903
3038
  const action = projection.actions.get(contentY);
2904
3039
  if (action) {
2905
- nestedTools.toggle(action);
3040
+ nestedTools.toggle(action, Boolean(this.expanded));
2906
3041
  this.updateDisplay?.();
2907
3042
  this.ui?.requestRender?.();
2908
- } else if (contentY === 0) {
2909
- originalSetExpanded.call(this, !this.expanded);
2910
- this.ui?.requestRender?.();
2911
3043
  }
3044
+ // The parent title is informational; only disclosure rows act on clicks.
2912
3045
  // Nested results are UI projections, not native renderer rows.
2913
3046
  return { handled: true };
2914
3047
  }
@@ -2988,6 +3121,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2988
3121
  }
2989
3122
 
2990
3123
  const finishCleanGroup = (activity = cleanRun.activity ?? "done") => {
3124
+ const activeGroup = activityTimeline.currentGroup();
2991
3125
  if (cleanRun.lastCompletedToolCallId && cleanRun.count > 0) {
2992
3126
  const group: ToolSummaryGroup = {
2993
3127
  count: cleanRun.count,
@@ -3014,6 +3148,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3014
3148
  cleanRun.activeToolCallId = undefined;
3015
3149
  cleanRun.activeToolName = undefined;
3016
3150
  activityTimeline.boundary();
3151
+ requestActivityRender(activeGroup);
3017
3152
  };
3018
3153
 
3019
3154
  closeCleanAtNativeCustomBoundary = () => {
@@ -3049,7 +3184,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3049
3184
  const activityText = /^thinking(?:\.\.\.)?$/iu.test(normalizedActivityText)
3050
3185
  ? "thinking"
3051
3186
  : rawActivityText;
3052
- const countLabel = count > 0 ? `${count} tool ${count === 1 ? "call" : "calls"}` : "";
3187
+ const totalCalls = count + nestedCount;
3188
+ const countLabel = totalCalls > 0 ? `${totalCalls} tool ${totalCalls === 1 ? "call" : "calls"}` : "";
3053
3189
  const thoughtLabel = thoughtCount > 0
3054
3190
  ? `${thoughtCount} ${thoughtCount === 1 ? "thought" : "thoughts"}`
3055
3191
  : "";
@@ -3058,7 +3194,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3058
3194
  const activityLabel = count > 0 && activityText !== "done" && activityText.trim()
3059
3195
  ? activityText
3060
3196
  : "";
3061
- return [countLabel, nestedCount ? `${nestedCount} nested ${nestedCount === 1 ? "call" : "calls"}` : "", thoughtLabel, activityLabel].filter(Boolean).join(" · ");
3197
+ return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
3062
3198
  };
3063
3199
 
3064
3200
  const summaryRow = (
@@ -3072,20 +3208,27 @@ export default function prettyTui(pi: ExtensionAPI) {
3072
3208
  ): DisplayRow => ({
3073
3209
  prefix: () => {
3074
3210
  const currentActivity = typeof activity === "function" ? activity() : activity;
3211
+ const dimPhase = responseRunStartedAt !== undefined &&
3212
+ Math.floor(Math.max(0, Date.now() - responseRunStartedAt) / RUNNING_DOT_INTERVAL_MS) % 2 === 1;
3075
3213
  const color = currentActivity === "done"
3076
- ? collapsedDone ? "thinkingText" : "success"
3077
- : "accent";
3078
- return theme.fg(color, "● ");
3214
+ ? collapsedDone ? "dim" : "success"
3215
+ : dimPhase ? "dim" : "accent";
3216
+ return currentActivity !== "done" && !dimPhase
3217
+ ? runningColor(theme, "● ")
3218
+ : theme.fg(color, "● ");
3079
3219
  },
3080
3220
  continuation: " ",
3081
3221
  content: () => {
3082
3222
  const currentActivity = typeof activity === "function" ? activity() : activity;
3083
3223
  const label = currentActivity === "done" ? "Done" : "Running";
3084
3224
  const color = label === "Done"
3085
- ? collapsedDone ? "thinkingText" : "success"
3225
+ ? collapsedDone ? "dim" : "success"
3086
3226
  : "accent";
3087
- const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
3088
- return theme.fg(color, theme.bold(label)) +
3227
+ const detailColor = label === "Done" && collapsedDone ? "dim" : "text";
3228
+ const title = label === "Running"
3229
+ ? runningColor(theme, theme.bold(label))
3230
+ : theme.fg(color, theme.bold(label));
3231
+ return title +
3089
3232
  theme.fg("dim", "(") +
3090
3233
  theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity, nestedCount)) +
3091
3234
  theme.fg("dim", ")");
@@ -3139,6 +3282,10 @@ export default function prettyTui(pi: ExtensionAPI) {
3139
3282
  const toolCount = group.toolCallIds.length;
3140
3283
  const nestedCount = group.toolCallIds.reduce((count, id) => count + nestedTools.count(id), 0);
3141
3284
  const live = () => cleanRun.activeToolName ?? currentCleanActivity();
3285
+ const currentGroup = activityTimeline.currentGroup();
3286
+ const isLiveGroup = currentGroup?.id === group.id &&
3287
+ !activityTimeline.isSettled(group.id) &&
3288
+ !cleanRun.settled;
3142
3289
  if (settled) {
3143
3290
  return fit(block([summaryRow(
3144
3291
  summaryTheme,
@@ -3150,8 +3297,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3150
3297
  nestedCount,
3151
3298
  )]).render(width));
3152
3299
  }
3153
- if (owner && !cleanRun.settled) {
3154
- // Live tool group: keep the current tool name and the running count.
3300
+ if (owner && isLiveGroup) {
3301
+ // Only the current, unsettled group may consume live run state.
3155
3302
  return fit(block([summaryRow(
3156
3303
  summaryTheme,
3157
3304
  Math.max(toolCount, liveCleanToolCount()),
@@ -3162,8 +3309,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3162
3309
  nestedCount,
3163
3310
  )]).render(width));
3164
3311
  }
3165
- if (!owner && !activityTimeline.isSettled(group.id)) {
3166
- // Thought-only group while the model is still working.
3312
+ if (!owner && isLiveGroup) {
3313
+ // Thought-only current group while the model is still working.
3167
3314
  return fit(block([summaryRow(
3168
3315
  summaryTheme,
3169
3316
  toolCount,
@@ -3197,6 +3344,11 @@ export default function prettyTui(pi: ExtensionAPI) {
3197
3344
  cleanToolThemes.set(toolCallId, theme);
3198
3345
  const activityGroup = activityTimeline.groupForTool(toolCallId);
3199
3346
  if (activityGroup) activityFallbackThemes.set(activityGroup.id, theme);
3347
+ const isLiveGroup = !activityGroup || (
3348
+ activityTimeline.currentGroup()?.id === activityGroup.id &&
3349
+ !activityTimeline.isSettled(activityGroup.id) &&
3350
+ !cleanRun.settled
3351
+ );
3200
3352
  return {
3201
3353
  render(width: number): string[] {
3202
3354
  if (renderMode !== "clean") return [];
@@ -3214,7 +3366,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3214
3366
  }
3215
3367
  if (cleanRun.settled) return [];
3216
3368
 
3217
- if (cleanRun.activeToolCallId === toolCallId) {
3369
+ if (isLiveGroup && cleanRun.activeToolCallId === toolCallId) {
3218
3370
  return block([summaryRow(
3219
3371
  theme,
3220
3372
  liveCleanToolCount(),
@@ -3227,6 +3379,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3227
3379
  // The active tool owns the live Running row. Keep the latest completed
3228
3380
  // portion hidden while that tool's minimum display time is running.
3229
3381
  if (
3382
+ isLiveGroup &&
3230
3383
  cleanRun.lastCompletedToolCallId === toolCallId &&
3231
3384
  cleanRun.count > 0 &&
3232
3385
  !cleanRun.activeToolCallId
@@ -3276,6 +3429,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3276
3429
  return {
3277
3430
  render(width: number): string[] {
3278
3431
  const data = entry.data;
3432
+ mouseClickHitTests.set(this, (event) => fullscreenTui && copyStart >= 0 &&
3433
+ event.y === 0 && event.x >= copyStart && event.x < copyStart + copyWidth);
3279
3434
  if (!data || width <= 0) return [];
3280
3435
  copyStart = -1;
3281
3436
  copyWidth = 0;
@@ -3830,6 +3985,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3830
3985
  cleanRun.active = true;
3831
3986
  cleanRun.activeToolCallId = event.toolCallId;
3832
3987
  beginToolActivity(event.toolCallId, displayName, true);
3988
+ requestActivityRender(activityTimeline.groupForTool(event.toolCallId));
3833
3989
  });
3834
3990
  pi.on("tool_execution_update", (event) => {
3835
3991
  if ((event as any).parentToolCallId) {
@@ -3871,6 +4027,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3871
4027
  }
3872
4028
  const visibleSummaryToolCallId = cleanRun.activeToolCallId ?? cleanRun.lastCompletedToolCallId;
3873
4029
  setCleanGroupMembers(visibleSummaryToolCallId, cleanRun.currentToolCallIds.slice());
4030
+ requestActivityRender(activityTimeline.groupForTool(event.toolCallId));
3874
4031
  // Tool success and failure remain owned by the activity group. The bottom
3875
4032
  // line is reserved for transient UI status messages.
3876
4033
  });
@@ -3910,6 +4067,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3910
4067
  cleanRun.activeToolName = undefined;
3911
4068
  cleanRun.activity = "done";
3912
4069
  cleanRun.settled = true;
4070
+ for (const group of activityTimeline.groups()) refreshActivityGroup(group);
4071
+ currentTui?.requestRender?.();
3913
4072
 
3914
4073
  if (responseRunStartedAt !== undefined) {
3915
4074
  // message_end runs before later extensions can replace the message and
@@ -53,9 +53,16 @@ const compactArgs = (value: string): string => {
53
53
  export class NestedTools {
54
54
  private calls = new Map<string, NestedCall>();
55
55
  private incomplete = new Set<string>();
56
- private expanded = new Set<string>();
57
- clear() { this.calls.clear(); this.incomplete.clear(); this.expanded.clear(); }
58
- toggle(key: string) { this.expanded.has(key) ? this.expanded.delete(key) : this.expanded.add(key); }
56
+ // Local overrides can close one disclosure even after global expansion.
57
+ private expanded = new Map<string, boolean>();
58
+ clearExpansion() { this.expanded.clear(); }
59
+ clear() { this.calls.clear(); this.incomplete.clear(); this.clearExpansion(); }
60
+ toggle(key: string, globallyExpanded = false) {
61
+ this.expanded.set(key, !(this.expanded.get(key) ?? globallyExpanded));
62
+ }
63
+ private isExpanded(key: string, globallyExpanded: boolean): boolean {
64
+ return this.expanded.get(key) ?? globallyExpanded;
65
+ }
59
66
  root(id: string): string {
60
67
  const seen = new Set<string>();
61
68
  while (this.calls.has(id) && !seen.has(id)) {
@@ -184,9 +191,9 @@ export class NestedTools {
184
191
  const actions = new Map<number, string>();
185
192
  const count = this.count(rootId);
186
193
  const failed = [...this.calls.values()].filter((call) => this.root(call.id) === rootId && call.status === "error").length;
187
- const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "accent";
194
+ const color = owner.result?.isError ? "error" : owner.result && !owner.isPartial ? "success" : "dim";
188
195
  const label = owner.toolDefinition?.label ?? owner.toolName;
189
- const lines = [theme.fg(color, "● ") + theme.fg("accent", theme.bold(label)) +
196
+ const lines = [theme.fg(color, "● ") + theme.fg("text", theme.bold(label)) +
190
197
  theme.fg("muted", `(${count} nested ${count === 1 ? "call" : "calls"}${failed ? ` · ${failed} failed` : ""})`)];
191
198
  const detail = (text: string, prefix: string, action?: string) => {
192
199
  const push = (line: string) => {
@@ -207,15 +214,15 @@ export class NestedTools {
207
214
  const visit = (call: NestedCall, indent: string, last: boolean, depth: number) => {
208
215
  const prefix = indent + (last ? "└─ " : "├─ ");
209
216
  const continuation = indent + (last ? " " : "│ ");
210
- const icon = call.status === "running" ? "…" : call.status === "cancelled" ? "⊘" : "●";
211
- const callColor = call.status === "ok" ? "success" : call.status === "error" ? "error" : call.status === "running" ? "accent" : "muted";
217
+ const icon = call.status === "cancelled" ? "⊘" : "●";
218
+ const callColor = call.status === "ok" ? "success" : call.status === "error" ? "error" : call.status === "running" ? "dim" : "muted";
212
219
  actions.set(lines.length, call.id);
213
220
  const duration = call.durationMs === undefined ? "" : ` · ${call.durationMs < 1000 ? `${Math.round(call.durationMs)}ms` : `${(call.durationMs / 1000).toFixed(1)}s`}`;
214
- lines.push(theme.fg("dim", prefix) + theme.fg(callColor, icon + " ") + theme.fg("accent", theme.bold(call.name)) + theme.fg("muted", `(${compactArgs(call.args)})${duration}`));
215
- const expanded = owner.expanded || this.expanded.has(call.id);
221
+ const descendants = this.children(call.id);
222
+ lines.push(theme.fg("dim", prefix) + theme.fg(callColor, icon + " ") + theme.fg("text", theme.bold(call.name)) + theme.fg("muted", `(${compactArgs(call.args)})${duration}`));
223
+ const expanded = this.isExpanded(call.id, Boolean(owner.expanded));
216
224
  const first = call.output?.split("\n").find((line) => line.trim());
217
225
  const summary = first ?? (call.status === "running" ? "Running…" : call.truncated ? "Output omitted by UI size limit" : call.output === undefined ? "Only call metadata retained" : "No text output");
218
- const descendants = this.children(call.id);
219
226
  actions.set(lines.length, call.id);
220
227
  lines.push(theme.fg("dim", continuation + (descendants.length ? "├ " : "└ ")) + theme.fg(call.status === "error" ? "error" : "muted", summary));
221
228
  if (expanded) {
@@ -231,13 +238,13 @@ export class NestedTools {
231
238
  if (code) {
232
239
  const key = `${rootId}:script`;
233
240
  actions.set(lines.length, key); lines.push(theme.fg("dim", " ├─ ") + theme.fg("mdLink", "[Script]"));
234
- if (owner.expanded || this.expanded.has(key)) detail(code, " │ ", key);
241
+ if (this.isExpanded(key, Boolean(owner.expanded))) detail(code, " │ ", key);
235
242
  }
236
243
  items.forEach((call) => visit(call, " ", false, 0));
237
244
  const key = `${rootId}:output`;
238
245
  actions.set(lines.length, key);
239
246
  lines.push(theme.fg("dim", " └─ ") + theme.fg("mdLink", code ? "[Script output]" : "[Output]"));
240
- if (owner.expanded || this.expanded.has(key)) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
247
+ if (this.isExpanded(key, Boolean(owner.expanded))) detail(resultText(owner.result) || (owner.isPartial || !owner.result ? "Running…" : "No text output"), " ", key);
241
248
  if (this.incomplete.has(rootId)) lines.push(theme.fg("warning", " [Nested call record is incomplete or truncated]"));
242
249
  return { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
243
250
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "A beautiful, polished TUI for the Pi coding agent",
5
5
  "license": "MIT",
6
6
  "author": "Kainan Yang <ykn0309@whu.edu.cn>",
@@ -39,8 +39,8 @@
39
39
  "@earendil-works/pi-tui": "*"
40
40
  },
41
41
  "devDependencies": {
42
- "@earendil-works/pi-coding-agent": "1.0.0",
43
- "@earendil-works/pi-tui": "1.0.0",
42
+ "@earendil-works/pi-coding-agent": "1.1.0",
43
+ "@earendil-works/pi-tui": "1.1.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "jiti": "^2.7.0",
46
46
  "typescript": "^5.9.0"