pi-pretty-tui 0.2.4 → 0.2.5

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 count only tool 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.
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
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.
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
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.
15
15
 
16
- Pi's built-in `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` tools receive purpose-built compact views. Every third-party tool automatically receives 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, mouse interaction, and tool execution behavior remain intact.
17
17
 
18
18
  ## Install
19
19
 
@@ -31,10 +31,22 @@ Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tool
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
 
34
+ In clean mode, Pi's native cache-miss notices join the thought/tool group of the assistant message that incurred the miss, as a yellow `⚠` child. They hide and reveal with the group and do not interrupt subsequent tool calls. If that message has no associated work (for example, a text-only final answer), the notice remains independent. Pi still decides whether a notice should appear and formats its token count, cost, model-switch and idle-gap labels; the integration hooks `addCacheMissNotice()` rather than matching text. Historical notices are re-derived on rebuild, not written as extra session entries. Full and compact modes retain Pi's native presentation.
35
+
34
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.
35
37
 
36
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.
37
39
 
40
+ ## Codemode and nested tools
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.
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.
45
+
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
+
48
+ These UI entries do not change the script's output or the model's tool results. They **do** save child text that a script may never have printed: treat session files as potentially sensitive. Common credential argument keys are redacted; tool output is not guaranteed to be free of secrets.
49
+
38
50
  ## Settings and rendering modes
39
51
 
40
52
  Run `/pretty-tui` to open the extension settings. The `Enabled` switch persists whether pi-pretty-tui installs its render patches and built-in tool renderers. Changing it automatically runs Pi's official reload flow, so no manual `/reload` is needed. You can also use `/pretty-tui enable` and `/pretty-tui disable` directly. While disabled, the settings command remains available and Pi uses its native rendering.
@@ -64,6 +76,8 @@ The suite covers live and restored activity timelines, retries and terminal stat
64
76
 
65
77
  ## Compatibility notice
66
78
 
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.
80
+
67
81
  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.
68
82
 
69
83
  No Pi source files are modified. Runtime patches are removed during session shutdown, but a future Pi release may require this extension to be updated.
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
11
  - **实时工作计时:** clean 模式下,耗时显示在输入框顶部边框的 `Working` 右侧,颜色与边框一致。它与完成 footer 共用 Agent 整轮工作的计时起点和时长格式,包含自动重试,工作真正结束后消失;窄输入框放不下时会隐藏计时。展开后的 `Done` 标题和圆点使用主题的 `success` 色,折叠摘要仍保持低调的灰色。
12
12
  - **临时 UI 状态:** 编辑器上方的一行只显示 Pi 直接产生的 UI 操作反馈,例如 Thinking 或工具输出可见性变化。新状态会替换旧状态,并在十秒后自动消失,不进入 transcript 或 session。
13
13
  - **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
14
14
  - **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用 accent 浅蓝色圆角边框,并移除了多余的 `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 原有的光标、输入法、自动补全、鼠标交互和工具执行行为。
17
17
 
18
18
  ## 安装
19
19
 
@@ -31,10 +31,22 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
31
31
 
32
32
  扩展调用 `ctx.ui.notify(message, "info")`(或省略 type)时,如果当前已有 thought/tool 活动组,通知会按真实时间顺序加入该组;如果没有现有工作,则独立显示,并且不会附着到未来的工具组。warning 和 error 保持独立,分别使用黄色 `⚠` 和红色 `✕`,并形成硬边界。工具失败继续归属于对应工具详情。上述扩展通知和错误都不会进入临时 UI 状态栏。
33
33
 
34
+ 在 clean 模式下,Pi 原生的 cache miss 提示以黄色 `⚠` 子节点加入产生该次缓存未命中的 assistant 消息所属的 thought/tool 活动组,随组折叠与展开,并且不会打断后续工具调用。如果该消息没有关联的工作(例如只输出正文的最终回答),提示保持独立。是否显示提示,以及 token 数、成本、模型切换和空闲时长等文案仍由 Pi 决定;接管的是 `addCacheMissNotice()` 入口,不匹配文本。历史提示在重建时重新推导,不额外写入会话记录。full 和 compact 模式保留 Pi 原生样式。
35
+
34
36
  没有自有 renderer 的 displayed custom message 使用统一、accent 配色的 `◇` Markdown 显示:当前存在 thought/tool 工作时加入当前组,否则独立显示,并保留 session 持久化。注册了 renderer 的消息完全保留插件原生显示并形成硬边界。所有判断只依据 Pi 的通知 type 和 renderer 是否存在,不识别扩展名称、custom message 名称,也不根据文本内容推断语义。
35
37
 
36
38
  Pi 内部直接调用 `showStatus()` 产生的反馈属于临时 UI 状态:它只显示在编辑器上方,新状态替换旧状态,并由十秒 timer 自动清除;它不会进入 transcript 或活动时间线,reload 时也会清除。在 clean 模式下,`Ctrl+T` 不修改 Pi 持久化的 Thinking 设置,只显示一条简短提示;thought 详情仅在点击对应子项时展开,`Ctrl+O` 继续负责全局活动展开。在 full 和 compact 模式下,`Ctrl+T` 保留 Pi 的原生行为。
37
39
 
40
+ ## Codemode 与嵌套工具
41
+
42
+ clean 模式下,通过 Pi 嵌套执行 API 调用其他工具的工具保留自己的父节点。展开活动组后,会显示各内部调用的状态、紧凑参数、耗时和结果摘要;点击某个内部调用,可以独立展开其参数和文本结果;点击展开后的正文可收起该调用,脚本和脚本输出正文也可点击收起各自区块。成功与失败的工具统一使用主题成功色与错误色的 `●` 圆点。`[Script]` 和 `[Script output]` 分别控制脚本与汇总输出;点击父工具或按 `Ctrl+O` 会展开全部详情。并行调用按照启动顺序排列,不会随完成顺序跳动。即使脚本捕获错误后成功,失败的子调用仍会明确标记。
43
+
44
+ 活动组会分别统计,例如 `1 tool call · 3 nested calls`。内部调用不会变成不存在的顶层 transcript 成员,也不会挤掉树尾折叠按钮。归属依据 Pi 的 `parentToolCallId`,不依赖工具名或 JavaScript 源码解析,因此其他嵌套调用工具也适用。这需要支持嵌套执行事件的 Pi(0.99.0 及以后);旧版本保持原有显示。full 和 compact 模式保留原生工具展示。
45
+
46
+ 子工具的文本结果和 edit diff 会存入隐藏的、仅用于 UI 的 session 条目,支持 reload 和会话分支恢复。每个父调用最多保留 256 个子调用,每项参数最多 4,096 字符,每项文本结果最多 8,192 字符,参数和结果合计最多 131,072 字符;截断会明确提示。每项展开预览还设有 200 行上限,嵌套树最多展示父调用以下八层。没有快照的旧会话会明确显示仅保留调用记录。目前子结果使用通用文本视图,不调用各第三方工具的 renderer,也不在快照中保存图片;父工具自身的媒体结果仍可在原生 full/compact 模式查看。
47
+
48
+ 这些 UI 条目不会修改脚本输出或模型收到的工具结果,但**会保存脚本可能未打印的子工具文本**,因此请将 session 文件视为可能含有敏感信息。常见凭证参数键会被遮盖,工具输出则不保证已清除所有敏感内容。
49
+
38
50
  ## 设置与渲染模式
39
51
 
40
52
  运行 `/pretty-tui` 可打开扩展设置。`Enabled` 开关会持久化控制 pi-pretty-tui 是否安装渲染补丁和内置工具 renderer。修改后会自动调用 Pi 官方的 reload 流程,无需手动执行 `/reload`。也可以直接使用 `/pretty-tui enable` 和 `/pretty-tui disable`。禁用期间设置命令仍然可用,Pi 会恢复原生渲染。
@@ -64,6 +76,8 @@ npm test
64
76
 
65
77
  ## 兼容性说明
66
78
 
79
+ 当前开发与回归测试基线为 **Pi 1.0.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
80
+
67
81
  工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
68
82
 
69
83
  本扩展不会修改 Pi 源文件。运行时补丁会在会话关闭时移除,但未来的 Pi 版本可能需要本扩展同步适配。
@@ -177,6 +177,44 @@ export class ActivityTimeline {
177
177
  return this.appendUpdate(group!, updateKey, title, content, persistent, severity);
178
178
  }
179
179
 
180
+ /** A native notice is inserted immediately after the message's last member,
181
+ * even when the rest of the branch was already restored into this timeline. */
182
+ addUpdateAfterMember(
183
+ anchorId: string,
184
+ updateKey: string,
185
+ title: string,
186
+ content: string,
187
+ severity: ActivitySeverity = "info",
188
+ ): ActivityMember | undefined {
189
+ const existing = this.memberForUpdate(updateKey);
190
+ if (existing) return existing;
191
+ const group = this.groupForMember(anchorId);
192
+ if (!group) return undefined;
193
+ const member = this.addUpdateToGroup(group.id, updateKey, title, content, false, severity);
194
+ if (!member) return undefined;
195
+ group.members.pop(); // addUpdateToGroup appended this new member
196
+ const index = group.members.findIndex((child) => child.id === anchorId);
197
+ group.members.splice(index + 1, 0, member);
198
+ return member;
199
+ }
200
+
201
+ /** Standalone derived notices must not change the live/last restored group. */
202
+ addStandaloneUpdate(
203
+ updateKey: string,
204
+ title: string,
205
+ content: string,
206
+ severity: ActivitySeverity = "info",
207
+ ): ActivityMember {
208
+ const existing = this.memberForUpdate(updateKey);
209
+ if (existing) return existing;
210
+ const previous = this.currentGroupId;
211
+ this.currentGroupId = undefined;
212
+ const member = this.addPendingUpdate(updateKey, title, content, false, severity);
213
+ this.boundary();
214
+ this.currentGroupId = previous;
215
+ return member;
216
+ }
217
+
180
218
  private appendUpdate(
181
219
  group: ActivityGroup,
182
220
  updateKey: string,
@@ -237,6 +275,24 @@ export class ActivityTimeline {
237
275
  return memberId ? this.member(memberId) : undefined;
238
276
  }
239
277
 
278
+ /** Remove a re-derived UI notice without splitting or settling real work. */
279
+ discardUpdate(updateKey: string): void {
280
+ const memberId = this.updateMembers.get(updateKey);
281
+ if (!memberId) return;
282
+ const group = this.groupForMember(memberId);
283
+ if (group) {
284
+ group.members = group.members.filter((member) => member.id !== memberId);
285
+ if (!group.members.length) {
286
+ this.groupsById.delete(group.id);
287
+ this.settledGroups.delete(group.id);
288
+ if (this.currentGroupId === group.id) this.currentGroupId = undefined;
289
+ }
290
+ }
291
+ this.updateMembers.delete(updateKey);
292
+ this.memberGroups.delete(memberId);
293
+ this.membersById.delete(memberId);
294
+ }
295
+
240
296
  /**
241
297
  * Releases a claimed update and splits the group at that point, so a native
242
298
  * custom renderer can take the message over without the remaining members
@@ -1,3 +1,4 @@
1
+ import { NestedTools, NESTED_SNAPSHOT_ENTRY } from "./nested-tools.js";
1
2
  import {
2
3
  AssistantMessageComponent,
3
4
  CustomEditor,
@@ -223,6 +224,11 @@ export default function prettyTui(pi: ExtensionAPI) {
223
224
  return `${hours}h ${String(minutes).padStart(2, "0")}m`;
224
225
  };
225
226
  const activityTimeline = new ActivityTimeline();
227
+ const nestedTools = new NestedTools();
228
+ const refreshNestedTools = (id: string) => {
229
+ cleanToolComponents.get(nestedTools.root(id))?.updateDisplay?.();
230
+ currentTui?.requestRender?.();
231
+ };
226
232
  const revealedActivityGroups = new Set<string>();
227
233
  // A local collapse must still work when Ctrl+O has expanded every group.
228
234
  const collapsedActivityGroups = new Set<string>();
@@ -236,6 +242,8 @@ export default function prettyTui(pi: ExtensionAPI) {
236
242
  component: Markdown;
237
243
  }>();
238
244
  let activityUpdateSequence = 0;
245
+ // Assigned by the interactive-mode patch below; tool events carry no `this`.
246
+ let flushDeferredCacheNotices: ((toolCallId: string) => void) | undefined;
239
247
  const assistantBoundaryKeys = new Set<string>();
240
248
  const cleanCompactToolCallIds = new Set<string>();
241
249
  const cleanGroupToolCallIds = new Map<string, string[]>();
@@ -373,7 +381,7 @@ export default function prettyTui(pi: ExtensionAPI) {
373
381
  const renderActivityCollapse = (group: ActivityGroup, width: number): string => {
374
382
  const theme = activityGroupTheme(group);
375
383
  return truncateToWidth(
376
- theme.fg("dim", collapsePrefix) + theme.fg("muted", collapseLabel),
384
+ theme.fg("dim", collapsePrefix) + theme.fg("mdLink", collapseLabel),
377
385
  Math.max(1, width),
378
386
  "",
379
387
  );
@@ -816,10 +824,16 @@ export default function prettyTui(pi: ExtensionAPI) {
816
824
  content.paddingY = 0;
817
825
  content.setBgFn?.(undefined);
818
826
  content.invalidate?.();
819
- // Pi wraps the Markdown in a full-width Box. Render the Markdown child
820
- // directly so short messages expose their natural visible width instead
821
- // of inheriting the Box's width-filling padding.
827
+ // Older Pi wraps Markdown in a Box; Pi 1.0 renders Markdown directly
828
+ // with its own background and padding. Normalize both layouts before
829
+ // measuring, preserving the native foreground and Markdown options.
822
830
  const markdown = content.children?.[0] ?? content;
831
+ markdown.paddingX = 0;
832
+ markdown.paddingY = 0;
833
+ if (markdown.defaultTextStyle) {
834
+ markdown.defaultTextStyle = { ...markdown.defaultTextStyle, bgColor: undefined };
835
+ }
836
+ markdown.invalidate?.();
823
837
 
824
838
  const outputPad = Math.max(0, Number(this.outputPad) || 0);
825
839
  const markdownTheme = this.markdownTheme;
@@ -1479,6 +1493,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1479
1493
  const originalShowStatus = interactiveModePrototype.showStatus;
1480
1494
  const originalShowWarning = interactiveModePrototype.showWarning;
1481
1495
  const originalShowError = interactiveModePrototype.showError;
1496
+ const originalAddCacheMissNotice = interactiveModePrototype.addCacheMissNotice;
1497
+ const originalMaybeShowCacheMissNotice = interactiveModePrototype.maybeShowCacheMissNotice;
1498
+ const originalAddMessageToChat = interactiveModePrototype.addMessageToChat;
1499
+ const cacheMessageContexts = new WeakMap<object, any>();
1500
+ const pendingCacheNotices = new Map<string, {
1501
+ key: string; title: string; content: string; toolCallIds: string[];
1502
+ }>();
1503
+ // The interactive-mode instance that owns the chat container. Tool events
1504
+ // carry no `this`, so deferred notices need this handle to mount.
1505
+ const cacheNoticeHost: { current?: any } = {};
1506
+ const cacheNoticeOwners = new WeakMap<object, any>();
1482
1507
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1483
1508
  cleanToolsExpanded = expanded;
1484
1509
  collapsedActivityGroups.clear();
@@ -1504,6 +1529,18 @@ export default function prettyTui(pi: ExtensionAPI) {
1504
1529
  // while Pi's live compaction UI appends it chronologically. Keep reloads
1505
1530
  // and transcript rebuilds consistent with that live presentation.
1506
1531
  const orderedEntries = orderContextEntriesForTranscript(entries);
1532
+ cacheMessageContexts.delete(this);
1533
+ // Pi re-derives notices on each rebuild (including when its display
1534
+ // setting changes). Remove old projections, not their work groups.
1535
+ for (const group of activityTimeline.groups()) {
1536
+ for (const member of [...group.members]) {
1537
+ if (member.updateKey?.startsWith("cache-miss:") || member.updateKey?.startsWith("runtime:cache-miss:")) {
1538
+ activityTimeline.discardUpdate(member.updateKey);
1539
+ activityUpdateComponents.delete(member.id);
1540
+ activityUpdateMarkdownComponents.delete(member.id);
1541
+ }
1542
+ }
1543
+ }
1507
1544
  return originalRenderSessionEntries.call(this, orderedEntries, options);
1508
1545
  };
1509
1546
 
@@ -1588,6 +1625,163 @@ export default function prettyTui(pi: ExtensionAPI) {
1588
1625
  }
1589
1626
  };
1590
1627
 
1628
+ // CacheMiss itself carries no assistant/message ID. Bind it at Pi's
1629
+ // calling sites, rather than depend on private chat-container children or
1630
+ // instanceof checks (these can be wrapped or come from another module).
1631
+ const patchedAddMessageToChat = function (this: any, message: any, ...args: any[]) {
1632
+ if (message?.role === "assistant") cacheMessageContexts.set(this, message);
1633
+ else cacheMessageContexts.delete(this);
1634
+ return originalAddMessageToChat.call(this, message, ...args);
1635
+ };
1636
+ const patchedMaybeShowCacheMissNotice = function (this: any, message: any, ...args: any[]) {
1637
+ const previous = cacheMessageContexts.get(this);
1638
+ cacheMessageContexts.set(this, message);
1639
+ try {
1640
+ return originalMaybeShowCacheMissNotice.call(this, message, ...args);
1641
+ } finally {
1642
+ if (previous) cacheMessageContexts.set(this, previous);
1643
+ else cacheMessageContexts.delete(this);
1644
+ }
1645
+ };
1646
+
1647
+ // One projection component per activity update member. Cache notices are
1648
+ // also mounted immediately after the tool that anchors them, so container
1649
+ // order matches group order even when the notice arrives before the tools.
1650
+ const mountActivityUpdate = (
1651
+ mode: any,
1652
+ group: ActivityGroup,
1653
+ member: ActivityMember,
1654
+ nativeNodes: Component[] = [],
1655
+ after?: Component,
1656
+ ): void => {
1657
+ const container = mode?.chatContainer;
1658
+ const existing = activityUpdateComponents.get(member.id);
1659
+ if (existing) {
1660
+ const children: Component[] | undefined = container?.children;
1661
+ if (after && children) {
1662
+ const target = children.indexOf(existing);
1663
+ const anchorIndex = children.indexOf(after);
1664
+ if (target >= 0 && anchorIndex >= 0 && target !== anchorIndex + 1) {
1665
+ children.splice(target, 1);
1666
+ children.splice(children.indexOf(after) + 1, 0, existing);
1667
+ }
1668
+ }
1669
+ return;
1670
+ }
1671
+ // A mode switch can keep existing components rather than rebuild them.
1672
+ const component: Component = {
1673
+ render: (width: number) => renderMode === "clean"
1674
+ ? renderActivityUpdateProjection(group, member, width)
1675
+ : nativeNodes.flatMap((node) => node.render(width)),
1676
+ invalidate() {},
1677
+ handleMouse: (event: any) => renderMode === "clean"
1678
+ ? handleActivityUpdateMouse(group, member, event)
1679
+ : undefined,
1680
+ } as Component;
1681
+ activityUpdateComponents.set(member.id, component);
1682
+ const index = after && container?.children ? container.children.indexOf(after) : -1;
1683
+ if (index >= 0) {
1684
+ container.children.splice(index + 1, 0, component);
1685
+ return;
1686
+ }
1687
+ container?.addChild?.(component);
1688
+ };
1689
+
1690
+ // Live cache misses arrive before the message's tools execute, so their
1691
+ // members do not exist yet. Attach the notice once its first tool joins the
1692
+ // group; this keeps it inside the tree instead of above it.
1693
+ const flushPendingCacheNotices = (toolCallId: string): void => {
1694
+ if (pendingCacheNotices.size === 0) return;
1695
+ for (const [messageKey, pending] of [...pendingCacheNotices]) {
1696
+ if (!pending.toolCallIds.includes(toolCallId)) continue;
1697
+ pendingCacheNotices.delete(messageKey);
1698
+ const anchorMember = activityTimeline.memberForTool(toolCallId);
1699
+ if (!anchorMember) continue;
1700
+ const member = activityTimeline.addUpdateAfterMember(
1701
+ anchorMember.id, pending.key, pending.title, pending.content, "warning",
1702
+ ) ?? activityTimeline.addStandaloneUpdate(
1703
+ pending.key, pending.title, pending.content, "warning",
1704
+ );
1705
+ const group = activityTimeline.groupForMember(member.id);
1706
+ if (!group) continue;
1707
+ mountActivityUpdate(
1708
+ cacheNoticeHost.current, group, member, [],
1709
+ cleanToolComponents.get(toolCallId),
1710
+ );
1711
+ refreshActivityGroup(group);
1712
+ cacheNoticeHost.current?.ui?.requestRender?.();
1713
+ }
1714
+ };
1715
+ flushDeferredCacheNotices = flushPendingCacheNotices;
1716
+
1717
+ const patchedAddCacheMissNotice = function (this: any, miss: any) {
1718
+ if (renderMode !== "clean" || !this.chatContainer?.addChild) {
1719
+ return originalAddCacheMissNotice.call(this, miss);
1720
+ }
1721
+ // Let Pi decide thresholds, formatting and cause labels. Capture its
1722
+ // native nodes rather than duplicating cache heuristics or matching text.
1723
+ const nativeNodes: Component[] = [];
1724
+ const captureHost = Object.create(this);
1725
+ captureHost.chatContainer = Object.create(this.chatContainer);
1726
+ captureHost.chatContainer.addChild = (node: Component) => nativeNodes.push(node);
1727
+ originalAddCacheMissNotice.call(captureHost, miss);
1728
+ const text = nativeNodes.flatMap((node) => node.render(4096))
1729
+ .map((line) => stripTerminalSequences(line).trim()).filter(Boolean).join("\n");
1730
+ if (!text) return;
1731
+
1732
+ // Rebuilds have already restored the *whole* branch. Use the assistant
1733
+ // physically preceding this insertion, not currentGroup() (the last one).
1734
+ const children: any[] = this.chatContainer.children ?? [];
1735
+ let message: any = cacheMessageContexts.get(this);
1736
+ for (let index = children.length - 1; !message && index >= 0; index--) {
1737
+ const child = children[index];
1738
+ if (cacheNoticeOwners.has(child)) { message = cacheNoticeOwners.get(child); break; }
1739
+ if (child instanceof ToolExecutionComponent) continue;
1740
+ if (child instanceof AssistantMessageComponent) { message = (child as any).lastMessage; break; }
1741
+ break; // never cross a user/custom/compaction boundary
1742
+ }
1743
+ // Live Pi may have inserted a provider diagnostic after the assistant.
1744
+ if (!message && this.streamingComponent && children.includes(this.streamingComponent)) {
1745
+ message = this.streamingMessage;
1746
+ }
1747
+ let anchor: ActivityMember | undefined;
1748
+ const calls = messageContentItems(message).filter((item: any) => item.type === "toolCall");
1749
+ for (let index = calls.length - 1; index >= 0; index--) {
1750
+ anchor = activityTimeline.memberForTool(calls[index].id);
1751
+ if (anchor) break;
1752
+ }
1753
+ if (!anchor && message && !visibleAssistantText(message)) {
1754
+ anchor = activityTimeline.memberForThinking(assistantMessageKey(message));
1755
+ }
1756
+ const key = message
1757
+ ? `cache-miss:${assistantMessageKey(message)}`
1758
+ : `runtime:cache-miss:${++activityUpdateSequence}`;
1759
+ const [title, ...details] = text.split("\n");
1760
+ const content = details.join("\n");
1761
+ cacheNoticeHost.current = this;
1762
+ if (!anchor && calls.length > 0) {
1763
+ // The tools have not started yet: their group does not exist. Defer so
1764
+ // the notice lands after the tool instead of floating above the tree.
1765
+ pendingCacheNotices.set(key, {
1766
+ key, title, content, toolCallIds: calls.map((call: any) => call.id),
1767
+ });
1768
+ return;
1769
+ }
1770
+ const member = (anchor && activityTimeline.addUpdateAfterMember(
1771
+ anchor.id, key, title, content, "warning",
1772
+ )) || activityTimeline.addStandaloneUpdate(key, title, content, "warning");
1773
+ const group = activityTimeline.groupForMember(member.id);
1774
+ if (!group) return;
1775
+ mountActivityUpdate(
1776
+ this, group, member, nativeNodes,
1777
+ anchor?.toolCallId ? cleanToolComponents.get(anchor.toolCallId) : undefined,
1778
+ );
1779
+ const component = activityUpdateComponents.get(member.id);
1780
+ if (component && message) cacheNoticeOwners.set(component, message);
1781
+ refreshActivityGroup(group);
1782
+ this.ui?.requestRender?.();
1783
+ };
1784
+
1591
1785
  interactiveModePrototype[toolsExpansionPatchKey] = {
1592
1786
  originalSetToolsExpanded,
1593
1787
  patchedSetToolsExpanded,
@@ -1605,6 +1799,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1605
1799
  patchedShowWarning,
1606
1800
  originalShowError,
1607
1801
  patchedShowError,
1802
+ originalAddCacheMissNotice,
1803
+ patchedAddCacheMissNotice,
1804
+ originalMaybeShowCacheMissNotice,
1805
+ patchedMaybeShowCacheMissNotice,
1806
+ originalAddMessageToChat,
1807
+ patchedAddMessageToChat,
1608
1808
  };
1609
1809
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
1610
1810
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
@@ -1614,6 +1814,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1614
1814
  interactiveModePrototype.showStatus = patchedShowStatus;
1615
1815
  interactiveModePrototype.showWarning = patchedShowWarning;
1616
1816
  interactiveModePrototype.showError = patchedShowError;
1817
+ if (typeof originalAddCacheMissNotice === "function") {
1818
+ interactiveModePrototype.addCacheMissNotice = patchedAddCacheMissNotice;
1819
+ if (typeof originalMaybeShowCacheMissNotice === "function") {
1820
+ interactiveModePrototype.maybeShowCacheMissNotice = patchedMaybeShowCacheMissNotice;
1821
+ }
1822
+ if (typeof originalAddMessageToChat === "function") {
1823
+ interactiveModePrototype.addMessageToChat = patchedAddMessageToChat;
1824
+ }
1825
+ }
1617
1826
 
1618
1827
  pi.on("session_shutdown", () => {
1619
1828
  fullscreenTui = false;
@@ -1646,6 +1855,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1646
1855
  if (patch.patchedShowError === interactiveModePrototype.showError) {
1647
1856
  interactiveModePrototype.showError = patch.originalShowError;
1648
1857
  }
1858
+ if (patch.patchedAddCacheMissNotice === interactiveModePrototype.addCacheMissNotice) {
1859
+ interactiveModePrototype.addCacheMissNotice = patch.originalAddCacheMissNotice;
1860
+ }
1861
+ if (patch.patchedMaybeShowCacheMissNotice === interactiveModePrototype.maybeShowCacheMissNotice) {
1862
+ interactiveModePrototype.maybeShowCacheMissNotice = patch.originalMaybeShowCacheMissNotice;
1863
+ }
1864
+ if (patch.patchedAddMessageToChat === interactiveModePrototype.addMessageToChat) {
1865
+ interactiveModePrototype.addMessageToChat = patch.originalAddMessageToChat;
1866
+ }
1649
1867
  if (
1650
1868
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
1651
1869
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
@@ -1654,7 +1872,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1654
1872
  interactiveModePrototype.toggleThinkingBlockVisibility === patch.originalToggleThinkingBlockVisibility &&
1655
1873
  interactiveModePrototype.showStatus === patch.originalShowStatus &&
1656
1874
  interactiveModePrototype.showWarning === patch.originalShowWarning &&
1657
- interactiveModePrototype.showError === patch.originalShowError
1875
+ interactiveModePrototype.showError === patch.originalShowError &&
1876
+ interactiveModePrototype.addCacheMissNotice === patch.originalAddCacheMissNotice &&
1877
+ interactiveModePrototype.maybeShowCacheMissNotice === patch.originalMaybeShowCacheMissNotice &&
1878
+ interactiveModePrototype.addMessageToChat === patch.originalAddMessageToChat
1658
1879
  ) {
1659
1880
  delete interactiveModePrototype[toolsExpansionPatchKey];
1660
1881
  }
@@ -2107,24 +2328,10 @@ export default function prettyTui(pi: ExtensionAPI) {
2107
2328
  context.state.compactToolStatus = status;
2108
2329
  };
2109
2330
 
2110
- const foregroundLuminance = (styled: string): number | undefined => {
2111
- const trueColor = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(styled);
2112
- if (!trueColor) return undefined;
2113
- const [, red, green, blue] = trueColor.map(Number);
2114
- return red * 0.2126 + green * 0.7152 + blue * 0.0722;
2115
- };
2116
-
2117
- const successMarkerColor = (theme: any): string => {
2118
- const textLuminance = foregroundLuminance(theme.fg("text", "M"));
2119
- return textLuminance !== undefined && textLuminance < 128
2120
- ? "success"
2121
- : "syntaxComment";
2122
- };
2123
-
2124
2331
  const callRow = (theme: any, name: string, detail: string, state: any): DisplayRow => ({
2125
2332
  prefix: () => {
2126
2333
  const status = (state.compactToolStatus ?? "running") as ToolStatus;
2127
- const color = status === "success" ? successMarkerColor(theme) : status === "error" ? "error" : "dim";
2334
+ const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
2128
2335
  return theme.fg(color, "● ");
2129
2336
  },
2130
2337
  continuation: " ",
@@ -2396,7 +2603,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2396
2603
  const dotColor = component.result?.isError
2397
2604
  ? "error"
2398
2605
  : component.result && !component.isPartial
2399
- ? successMarkerColor(theme)
2606
+ ? "success"
2400
2607
  : "accent";
2401
2608
  const title = theme.fg(dotColor, "● ") +
2402
2609
  theme.fg("accent", theme.bold(label)) +
@@ -2585,7 +2792,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2585
2792
  // not one of our self-rendering specialized tools.
2586
2793
  const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName) ||
2587
2794
  this.toolDefinition?.renderShell !== "self";
2588
- const cacheable = !this.expanded && Boolean(this.result) && !this.isPartial;
2795
+ const cacheable = nestedTools.count(this.toolCallId) === 0 && !this.expanded && Boolean(this.result) && !this.isPartial;
2589
2796
  const cached = cacheable ? this[cleanChildRenderCacheKey] : undefined;
2590
2797
  let decoratedContent: string[];
2591
2798
  if (
@@ -2602,7 +2809,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2602
2809
  decoratedContent = cached.lines;
2603
2810
  } else {
2604
2811
  let contentLines: string[];
2605
- if (thirdParty) {
2812
+ if (nestedTools.count(this.toolCallId) > 0) {
2813
+ contentLines = nestedTools.render(this, childWidth, childTheme).lines;
2814
+ } else if (thirdParty) {
2606
2815
  const summaryLines = renderThirdPartyCompact(this, childWidth, childTheme);
2607
2816
  if (this.expanded) {
2608
2817
  const detailPrefix = " │ ";
@@ -2686,6 +2895,24 @@ export default function prettyTui(pi: ExtensionAPI) {
2686
2895
  return { handled: true };
2687
2896
  }
2688
2897
 
2898
+ if (nestedTools.count(this.toolCallId) > 0 && isLeftClick) {
2899
+ const theme = cleanThemeForToolCall(this.toolCallId) ?? activityGroupTheme(group);
2900
+ const { childWidth } = activityTreeStyle(group, member, event.width, theme);
2901
+ const projection = nestedTools.render(this, childWidth, theme);
2902
+ const contentY = event.y - (position.first ? summaryHeight + 1 : 0);
2903
+ const action = projection.actions.get(contentY);
2904
+ if (action) {
2905
+ nestedTools.toggle(action);
2906
+ this.updateDisplay?.();
2907
+ this.ui?.requestRender?.();
2908
+ } else if (contentY === 0) {
2909
+ originalSetExpanded.call(this, !this.expanded);
2910
+ this.ui?.requestRender?.();
2911
+ }
2912
+ // Nested results are UI projections, not native renderer rows.
2913
+ return { handled: true };
2914
+ }
2915
+
2689
2916
  // Self-rendering tools reserve y=0 for Pi's leading spacer. Clean mode
2690
2917
  // replaces that spacer with the compact header for non-first members.
2691
2918
  const thirdPartyHeaderY = position.first ? summaryHeight + 1 : 0;
@@ -2815,6 +3042,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2815
3042
  _failed: number,
2816
3043
  thoughtCount: number,
2817
3044
  activity = "done",
3045
+ nestedCount = 0,
2818
3046
  ): string => {
2819
3047
  const rawActivityText = typeof activity === "string" ? activity : "done";
2820
3048
  const normalizedActivityText = rawActivityText.trim();
@@ -2830,7 +3058,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2830
3058
  const activityLabel = count > 0 && activityText !== "done" && activityText.trim()
2831
3059
  ? activityText
2832
3060
  : "";
2833
- return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
3061
+ return [countLabel, nestedCount ? `${nestedCount} nested ${nestedCount === 1 ? "call" : "calls"}` : "", thoughtLabel, activityLabel].filter(Boolean).join(" · ");
2834
3062
  };
2835
3063
 
2836
3064
  const summaryRow = (
@@ -2840,6 +3068,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2840
3068
  thoughtCount: number,
2841
3069
  activity: DisplayValue = "done",
2842
3070
  collapsedDone = false,
3071
+ nestedCount = 0,
2843
3072
  ): DisplayRow => ({
2844
3073
  prefix: () => {
2845
3074
  const currentActivity = typeof activity === "function" ? activity() : activity;
@@ -2858,7 +3087,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2858
3087
  const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
2859
3088
  return theme.fg(color, theme.bold(label)) +
2860
3089
  theme.fg("dim", "(") +
2861
- theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity)) +
3090
+ theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity, nestedCount)) +
2862
3091
  theme.fg("dim", ")");
2863
3092
  },
2864
3093
  });
@@ -2908,6 +3137,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2908
3137
  const owner = activityGroupOwner(group);
2909
3138
  const settled = owner ? settledSummaries.get(owner) : undefined;
2910
3139
  const toolCount = group.toolCallIds.length;
3140
+ const nestedCount = group.toolCallIds.reduce((count, id) => count + nestedTools.count(id), 0);
2911
3141
  const live = () => cleanRun.activeToolName ?? currentCleanActivity();
2912
3142
  if (settled) {
2913
3143
  return fit(block([summaryRow(
@@ -2917,6 +3147,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2917
3147
  Math.max(settled.thoughtCount, group.thoughtCount),
2918
3148
  settled.activity,
2919
3149
  collapsedDone,
3150
+ nestedCount,
2920
3151
  )]).render(width));
2921
3152
  }
2922
3153
  if (owner && !cleanRun.settled) {
@@ -2928,6 +3159,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2928
3159
  group.thoughtCount,
2929
3160
  live,
2930
3161
  collapsedDone,
3162
+ nestedCount,
2931
3163
  )]).render(width));
2932
3164
  }
2933
3165
  if (!owner && !activityTimeline.isSettled(group.id)) {
@@ -2939,6 +3171,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2939
3171
  group.thoughtCount,
2940
3172
  "thinking",
2941
3173
  collapsedDone,
3174
+ nestedCount,
2942
3175
  )]).render(width));
2943
3176
  }
2944
3177
  // Finished group with no durable summary: a completed Thought-only turn, or
@@ -2950,6 +3183,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2950
3183
  group.thoughtCount,
2951
3184
  "done",
2952
3185
  collapsedDone,
3186
+ nestedCount,
2953
3187
  )]).render(width));
2954
3188
  };
2955
3189
 
@@ -3148,6 +3382,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3148
3382
  settledSummaries.clear();
3149
3383
  legacySummaryLastToolCallIds.clear();
3150
3384
  activityTimeline.clear();
3385
+ nestedTools.clear();
3151
3386
  responseAnswerTexts.clear();
3152
3387
  responseRunStartedAt = undefined;
3153
3388
  responseHasAssistantMessage = false;
@@ -3307,6 +3542,10 @@ export default function prettyTui(pi: ExtensionAPI) {
3307
3542
  cleanContextCompacted = modelContextEntries[0]?.type === "compaction";
3308
3543
  const contextEntries = orderContextEntriesForTranscript(modelContextEntries);
3309
3544
  for (const entry of contextEntries) {
3545
+ if (entry.type === "custom" && entry.customType === NESTED_SNAPSHOT_ENTRY) {
3546
+ nestedTools.restore(entry.data);
3547
+ continue;
3548
+ }
3310
3549
  if (entry.type === "compaction") {
3311
3550
  finishGroup();
3312
3551
  lastFinishedGroup = undefined;
@@ -3391,6 +3630,10 @@ export default function prettyTui(pi: ExtensionAPI) {
3391
3630
  continue;
3392
3631
  }
3393
3632
 
3633
+ if (message.role === "toolResult") {
3634
+ nestedTools.absorb(message.toolCallId, message.nestedCalls ?? message.details?.calls, !message.details?.callsTruncated);
3635
+ }
3636
+
3394
3637
  if (
3395
3638
  message.role === "toolResult" &&
3396
3639
  toolCalls.has(message.toolCallId) &&
@@ -3473,6 +3716,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3473
3716
  if (toolCalls.length === 0) return false;
3474
3717
  for (const toolCall of toolCalls) {
3475
3718
  activityTimeline.addTool(toolCall.id, toolCall.name ?? "tool");
3719
+ flushDeferredCacheNotices?.(toolCall.id);
3476
3720
  if (!cleanRun.currentToolCallIds.includes(toolCall.id)) {
3477
3721
  cleanRun.currentToolCallIds.push(toolCall.id);
3478
3722
  }
@@ -3569,7 +3813,13 @@ export default function prettyTui(pi: ExtensionAPI) {
3569
3813
  if (renderMode === "clean") startWorkingElapsedTimer();
3570
3814
  });
3571
3815
  pi.on("tool_execution_start", (event) => {
3816
+ if ((event as any).parentToolCallId) {
3817
+ nestedTools.start(event);
3818
+ refreshNestedTools(event.toolCallId);
3819
+ return;
3820
+ }
3572
3821
  activityTimeline.addTool(event.toolCallId, event.toolName);
3822
+ flushDeferredCacheNotices?.(event.toolCallId);
3573
3823
  if (!cleanRun.currentToolCallIds.includes(event.toolCallId)) {
3574
3824
  cleanRun.currentToolCallIds.push(event.toolCallId);
3575
3825
  }
@@ -3581,7 +3831,25 @@ export default function prettyTui(pi: ExtensionAPI) {
3581
3831
  cleanRun.activeToolCallId = event.toolCallId;
3582
3832
  beginToolActivity(event.toolCallId, displayName, true);
3583
3833
  });
3834
+ pi.on("tool_execution_update", (event) => {
3835
+ if ((event as any).parentToolCallId) {
3836
+ nestedTools.update(event);
3837
+ refreshNestedTools(event.toolCallId);
3838
+ } else {
3839
+ nestedTools.absorb(event.toolCallId, (event.partialResult as any)?.details?.calls, !(event.partialResult as any)?.details?.callsTruncated);
3840
+ refreshNestedTools(event.toolCallId);
3841
+ }
3842
+ });
3584
3843
  pi.on("tool_execution_end", (event) => {
3844
+ if ((event as any).parentToolCallId) {
3845
+ nestedTools.update(event, true);
3846
+ refreshNestedTools(event.toolCallId);
3847
+ return;
3848
+ }
3849
+ nestedTools.absorb(event.toolCallId, (event.result as any)?.details?.calls, !(event.result as any)?.details?.callsTruncated);
3850
+ nestedTools.finish(event.toolCallId);
3851
+ const snapshot = nestedTools.snapshot(event.toolCallId);
3852
+ if (snapshot) pi.appendEntry(NESTED_SNAPSHOT_ENTRY, snapshot);
3585
3853
  const activityName = cleanRun.activeToolCallId === event.toolCallId
3586
3854
  ? cleanRun.activeToolName ?? cleanToolNames.get(event.toolCallId) ?? event.toolName
3587
3855
  : cleanToolNames.get(event.toolCallId) ?? event.toolName;
@@ -3618,6 +3886,11 @@ export default function prettyTui(pi: ExtensionAPI) {
3618
3886
  pi.on("agent_settled", (_event, ctx) => {
3619
3887
  stopWorkingElapsedTimer();
3620
3888
  if (cleanRun.settled) return;
3889
+ for (const rootId of nestedTools.finishAll()) {
3890
+ const snapshot = nestedTools.snapshot(rootId);
3891
+ if (snapshot) pi.appendEntry(NESTED_SNAPSHOT_ENTRY, snapshot);
3892
+ refreshNestedTools(rootId);
3893
+ }
3621
3894
 
3622
3895
  clearPendingToolActivities();
3623
3896
  // Finalize the last group after retries/compaction have definitely ended.
@@ -0,0 +1,244 @@
1
+ import { stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+
3
+ export const NESTED_SNAPSHOT_ENTRY = "pretty-tui-nested-results";
4
+ export const NESTED_LIMITS = { calls: 256, args: 4096, output: 8192, total: 131072 };
5
+ type Status = "running" | "ok" | "error" | "cancelled";
6
+ export type NestedCall = {
7
+ id: string; parentId: string; name: string; args: string; status: Status;
8
+ startedAt?: number; durationMs?: number; output?: string; truncated?: boolean; argsTruncated?: boolean;
9
+ };
10
+ export type NestedSnapshot = { version: 1; rootId: string; calls: NestedCall[]; incomplete: boolean };
11
+ const safeText = (value: string) => stripTerminalSequences(value).replace(/[\x00-\x08\x0b-\x1f\x7f]/gu, "");
12
+ const bounded = (text: string, limit: number) => safeText(text).slice(0, limit);
13
+ const argsText = (args: any): string => {
14
+ try {
15
+ return bounded(JSON.stringify(args ?? {}, (key, value) =>
16
+ /token|secret|password|credential|authorization|api[_-]?key/iu.test(key) ? "[redacted]" : value,
17
+ ), NESTED_LIMITS.args);
18
+ } catch { return "[arguments unavailable]"; }
19
+ };
20
+ const recordedArgs = (value: string): string => {
21
+ try { return argsText(JSON.parse(value)); } catch {
22
+ return bounded(value.replace(/("[^"\n]*(?:token|secret|password|credential|authorization|api[_-]?key)[^"\n]*"\s*:\s*)("(?:\\.|[^"\\])*"|[^,}\n]*)/giu, '$1"[redacted]"'), NESTED_LIMITS.args);
23
+ }
24
+ };
25
+ const resultText = (result: any): string => {
26
+ const text = (Array.isArray(result?.content) ? result.content : [])
27
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
28
+ .map((block: any) => block.text).join("\n");
29
+ // Edit renderers carry their useful diff separately from the success message.
30
+ const diff = typeof result?.details?.diff === "string" ? result.details.diff : "";
31
+ let structured = "";
32
+ if (!text && result?.structuredContent !== undefined) {
33
+ try { structured = JSON.stringify(result.structuredContent, null, 2); } catch { /* non-JSON result */ }
34
+ }
35
+ const media = (result?.content ?? []).some((block: any) => block?.type === "image")
36
+ ? "[Image result omitted from nested text preview]" : "";
37
+ const error = !text && typeof result?.details?.error === "string" ? result.details.error : "";
38
+ return safeText([text, diff, structured, media, error].filter(Boolean).join("\n"));
39
+ };
40
+
41
+ const compactArgs = (value: string): string => {
42
+ try {
43
+ const args = JSON.parse(value);
44
+ if (!args || typeof args !== "object" || Array.isArray(args)) return value;
45
+ return Object.entries(args)
46
+ .filter(([key]) => !/token|secret|password|credential|authorization|api[_-]?key/iu.test(key))
47
+ .slice(0, 3).map(([key, entry]) => `${key}=${typeof entry === "string" ? entry : JSON.stringify(entry)}`)
48
+ .join(" · ").replace(/\s+/gu, " ");
49
+ } catch { return value.replace(/\s+/gu, " "); }
50
+ };
51
+
52
+ /** UI-only nested execution state. Never executes tools or alters model results. */
53
+ export class NestedTools {
54
+ private calls = new Map<string, NestedCall>();
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); }
59
+ root(id: string): string {
60
+ const seen = new Set<string>();
61
+ while (this.calls.has(id) && !seen.has(id)) {
62
+ seen.add(id); id = this.calls.get(id)!.parentId;
63
+ }
64
+ return id;
65
+ }
66
+ children(parentId: string): NestedCall[] {
67
+ return [...this.calls.values()].filter((call) => call.parentId === parentId);
68
+ }
69
+ count(rootId: string): number {
70
+ return [...this.calls.values()].filter((call) => this.root(call.id) === rootId).length;
71
+ }
72
+ start(event: any) {
73
+ if (!event.parentToolCallId || this.calls.has(event.toolCallId)) return;
74
+ const rootId = this.root(event.parentToolCallId);
75
+ if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); return; }
76
+ const args = argsText(event.args);
77
+ const argsTruncated = args.length >= NESTED_LIMITS.args;
78
+ if (argsTruncated) this.incomplete.add(rootId);
79
+ this.calls.set(event.toolCallId, {
80
+ id: event.toolCallId, parentId: event.parentToolCallId, name: bounded(String(event.toolName ?? "tool"), 256),
81
+ args, argsTruncated, status: "running", startedAt: Date.now(),
82
+ });
83
+ }
84
+ update(event: any, finished = false) {
85
+ const call = this.calls.get(event.toolCallId);
86
+ if (!call) return;
87
+ const text = resultText(finished ? event.result : event.partialResult);
88
+ const rootId = this.root(call.id);
89
+ const otherSize = [...this.calls.values()].filter((other) => other.id !== call.id && this.root(other.id) === rootId)
90
+ .reduce((size, other) => size + other.args.length + (other.output?.length ?? 0), 0);
91
+ const limit = Math.max(0, Math.min(NESTED_LIMITS.output, NESTED_LIMITS.total - otherSize - call.args.length));
92
+ call.output = text.slice(0, limit);
93
+ call.truncated = text.length > limit;
94
+ if (call.truncated) this.incomplete.add(rootId);
95
+ if (finished) {
96
+ call.status = event.isError ? "error" : "ok";
97
+ call.durationMs = Math.max(0, Date.now() - (call.startedAt ?? Date.now()));
98
+ }
99
+ }
100
+ finish(rootId: string): boolean {
101
+ let changed = false;
102
+ for (const call of this.calls.values()) {
103
+ if (this.root(call.id) === rootId && call.status === "running") {
104
+ call.status = "cancelled"; changed = true;
105
+ }
106
+ }
107
+ return changed;
108
+ }
109
+ finishAll(): string[] {
110
+ const roots = new Set([...this.calls.values()].map((call) => this.root(call.id)));
111
+ return [...roots].filter((rootId) => this.finish(rootId));
112
+ }
113
+ /** Persisted host metadata is a fallback, not a substitute for actual results. */
114
+ absorb(rootId: string, records: any, complete = true) {
115
+ if (records && !Array.isArray(records) && Array.isArray(records.calls)) {
116
+ complete &&= records.complete !== false;
117
+ records = records.calls;
118
+ }
119
+ if (!Array.isArray(records)) return;
120
+ for (const record of records) {
121
+ if (typeof record?.id !== "string" || typeof record?.name !== "string" || record.id.endsWith("/?")) continue;
122
+ const existing = this.calls.get(record.id);
123
+ if (existing) {
124
+ if (record.status === "cancelled" || record.status === "unfinished") existing.status = "cancelled";
125
+ else if (record.status === "error" || record.status === "ok") existing.status = record.status;
126
+ if (typeof record.durationMs === "number") existing.durationMs = record.durationMs;
127
+ continue;
128
+ }
129
+ if (this.count(rootId) >= NESTED_LIMITS.calls) { this.incomplete.add(rootId); break; }
130
+ const slash = record.id.lastIndexOf("/");
131
+ const candidate = record.id.slice(0, slash);
132
+ const parentId = this.calls.has(candidate) ? candidate : rootId;
133
+ this.calls.set(record.id, {
134
+ id: record.id, parentId, name: bounded(record.name, 256),
135
+ args: typeof record.args === "string" ? recordedArgs(record.args) : record.argumentsBytes !== undefined ? "[Arguments omitted by Pi]" : argsText(record.arguments),
136
+ argsTruncated: record.argumentsBytes !== undefined,
137
+ status: record.status === "error" ? "error" : record.status === "cancelled" || record.status === "unfinished" ? "cancelled" : record.status === "running" ? "running" : "ok",
138
+ durationMs: typeof record.durationMs === "number" ? record.durationMs : undefined,
139
+ output: typeof record.error === "string" ? bounded(record.error, NESTED_LIMITS.output) : undefined,
140
+ });
141
+ }
142
+ if (!complete) this.incomplete.add(rootId);
143
+ }
144
+ snapshot(rootId: string): NestedSnapshot | undefined {
145
+ const calls: NestedCall[] = [];
146
+ let budget = NESTED_LIMITS.total;
147
+ let incomplete = this.incomplete.has(rootId);
148
+ for (const call of this.calls.values()) {
149
+ if (this.root(call.id) !== rootId) continue;
150
+ const { startedAt: _startedAt, ...copy } = call;
151
+ copy.args = copy.args.slice(0, Math.max(0, budget));
152
+ budget -= copy.args.length;
153
+ if (copy.output !== undefined) {
154
+ copy.output = copy.output.slice(0, Math.max(0, budget));
155
+ budget -= copy.output.length;
156
+ copy.truncated ||= copy.output.length < (call.output?.length ?? 0);
157
+ }
158
+ copy.argsTruncated ||= copy.args.length < call.args.length;
159
+ incomplete ||= Boolean(copy.argsTruncated || copy.truncated);
160
+ if (copy.status === "running") copy.status = "cancelled";
161
+ calls.push(copy);
162
+ }
163
+ if (!calls.length) return undefined;
164
+ return { version: 1, rootId, calls, incomplete };
165
+ }
166
+ restore(data: any) {
167
+ if (data?.version !== 1 || typeof data.rootId !== "string" || !Array.isArray(data.calls)) return;
168
+ // Snapshot entries originate locally; still validate and bound every field.
169
+ let budget = NESTED_LIMITS.total;
170
+ for (const call of data.calls.slice(0, NESTED_LIMITS.calls)) {
171
+ if (typeof call?.id !== "string" || typeof call.parentId !== "string" || typeof call.name !== "string") continue;
172
+ const args = bounded(String(call.args ?? ""), Math.min(NESTED_LIMITS.args, budget));
173
+ budget -= args.length;
174
+ const output = typeof call.output === "string" ? bounded(call.output, Math.min(NESTED_LIMITS.output, budget)) : undefined;
175
+ budget -= output?.length ?? 0;
176
+ this.calls.set(call.id, { id: call.id, parentId: call.parentId, name: bounded(call.name, 256), args, output,
177
+ status: call.status === "error" ? "error" : call.status === "cancelled" || call.status === "running" ? "cancelled" : "ok",
178
+ durationMs: typeof call.durationMs === "number" ? call.durationMs : undefined, truncated: Boolean(call.truncated), argsTruncated: Boolean(call.argsTruncated) });
179
+ }
180
+ if (data.incomplete) this.incomplete.add(data.rootId);
181
+ }
182
+ render(owner: any, width: number, theme: any): { lines: string[]; actions: Map<number, string> } {
183
+ const rootId = owner.toolCallId;
184
+ const actions = new Map<number, string>();
185
+ const count = this.count(rootId);
186
+ 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";
188
+ const label = owner.toolDefinition?.label ?? owner.toolName;
189
+ const lines = [theme.fg(color, "● ") + theme.fg("accent", theme.bold(label)) +
190
+ theme.fg("muted", `(${count} nested ${count === 1 ? "call" : "calls"}${failed ? ` · ${failed} failed` : ""})`)];
191
+ const detail = (text: string, prefix: string, action?: string) => {
192
+ const push = (line: string) => {
193
+ if (action) actions.set(lines.length, action);
194
+ lines.push(line);
195
+ };
196
+ const available = Math.max(1, width - visibleWidth(prefix));
197
+ let rows = 0;
198
+ for (const line of bounded(text, NESTED_LIMITS.output).split("\n")) {
199
+ for (const wrapped of wrapTextWithAnsi(line || " ", available)) {
200
+ if (++rows > 200) { push(theme.fg("dim", prefix + "[Preview truncated]")); return; }
201
+ push(theme.fg("dim", prefix) + theme.fg("toolOutput", wrapped));
202
+ }
203
+ }
204
+ if (safeText(text).length > NESTED_LIMITS.output) push(theme.fg("dim", prefix + "[Preview truncated]"));
205
+ };
206
+ const items = this.children(rootId);
207
+ const visit = (call: NestedCall, indent: string, last: boolean, depth: number) => {
208
+ const prefix = indent + (last ? "└─ " : "├─ ");
209
+ 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";
212
+ actions.set(lines.length, call.id);
213
+ 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);
216
+ const first = call.output?.split("\n").find((line) => line.trim());
217
+ 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
+ actions.set(lines.length, call.id);
220
+ lines.push(theme.fg("dim", continuation + (descendants.length ? "├ " : "└ ")) + theme.fg(call.status === "error" ? "error" : "muted", summary));
221
+ if (expanded) {
222
+ detail(`Arguments: ${call.args}`, continuation + " ", call.id);
223
+ if (call.output) detail(call.output, continuation + " ", call.id);
224
+ if (call.argsTruncated) detail("[Arguments truncated in UI snapshot]", continuation + " ", call.id);
225
+ if (call.truncated) detail("[Output truncated in UI snapshot]", continuation + " ", call.id);
226
+ }
227
+ if (depth < 7) descendants.forEach((child, index, children) => visit(child, continuation, index === children.length - 1, depth + 1));
228
+ else if (descendants.length) detail("[Deeper calls omitted]", continuation);
229
+ };
230
+ const code = typeof owner.args?.code === "string" ? owner.args.code : undefined;
231
+ if (code) {
232
+ const key = `${rootId}:script`;
233
+ 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);
235
+ }
236
+ items.forEach((call) => visit(call, " ", false, 0));
237
+ const key = `${rootId}:output`;
238
+ actions.set(lines.length, key);
239
+ 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);
241
+ if (this.incomplete.has(rootId)) lines.push(theme.fg("warning", " [Nested call record is incomplete or truncated]"));
242
+ return { lines: lines.map((line) => truncateToWidth(line, Math.max(1, width), "…")), actions };
243
+ }
244
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.4",
3
+ "version": "0.2.5",
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": "0.85.1",
43
- "@earendil-works/pi-tui": "0.85.1",
42
+ "@earendil-works/pi-coding-agent": "1.0.0",
43
+ "@earendil-works/pi-tui": "1.0.0",
44
44
  "@types/node": "^24.0.0",
45
45
  "jiti": "^2.7.0",
46
46
  "typescript": "^5.9.0"