pi-pretty-tui 0.2.3 → 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,13 +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
+ - **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.
10
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.
11
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.
12
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.
13
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
15
 
15
- 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.
16
17
 
17
18
  ## Install
18
19
 
@@ -30,10 +31,22 @@ Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tool
30
31
 
31
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.
32
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
+
33
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.
34
37
 
35
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.
36
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
+
37
50
  ## Settings and rendering modes
38
51
 
39
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.
@@ -63,6 +76,8 @@ The suite covers live and restored activity timelines, retries and terminal stat
63
76
 
64
77
  ## Compatibility notice
65
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
+
66
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.
67
82
 
68
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,13 +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
12
  - **临时 UI 状态:** 编辑器上方的一行只显示 Pi 直接产生的 UI 操作反馈,例如 Thinking 或工具输出可见性变化。新状态会替换旧状态,并在十秒后自动消失,不进入 transcript 或 session。
12
13
  - **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
13
14
  - **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用 accent 浅蓝色圆角边框,并移除了多余的 `User` 标注。配合圆角输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
14
15
 
15
- 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 原有的光标、输入法、自动补全、鼠标交互和工具执行行为。
16
17
 
17
18
  ## 安装
18
19
 
@@ -30,10 +31,22 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
30
31
 
31
32
  扩展调用 `ctx.ui.notify(message, "info")`(或省略 type)时,如果当前已有 thought/tool 活动组,通知会按真实时间顺序加入该组;如果没有现有工作,则独立显示,并且不会附着到未来的工具组。warning 和 error 保持独立,分别使用黄色 `⚠` 和红色 `✕`,并形成硬边界。工具失败继续归属于对应工具详情。上述扩展通知和错误都不会进入临时 UI 状态栏。
32
33
 
34
+ 在 clean 模式下,Pi 原生的 cache miss 提示以黄色 `⚠` 子节点加入产生该次缓存未命中的 assistant 消息所属的 thought/tool 活动组,随组折叠与展开,并且不会打断后续工具调用。如果该消息没有关联的工作(例如只输出正文的最终回答),提示保持独立。是否显示提示,以及 token 数、成本、模型切换和空闲时长等文案仍由 Pi 决定;接管的是 `addCacheMissNotice()` 入口,不匹配文本。历史提示在重建时重新推导,不额外写入会话记录。full 和 compact 模式保留 Pi 原生样式。
35
+
33
36
  没有自有 renderer 的 displayed custom message 使用统一、accent 配色的 `◇` Markdown 显示:当前存在 thought/tool 工作时加入当前组,否则独立显示,并保留 session 持久化。注册了 renderer 的消息完全保留插件原生显示并形成硬边界。所有判断只依据 Pi 的通知 type 和 renderer 是否存在,不识别扩展名称、custom message 名称,也不根据文本内容推断语义。
34
37
 
35
38
  Pi 内部直接调用 `showStatus()` 产生的反馈属于临时 UI 状态:它只显示在编辑器上方,新状态替换旧状态,并由十秒 timer 自动清除;它不会进入 transcript 或活动时间线,reload 时也会清除。在 clean 模式下,`Ctrl+T` 不修改 Pi 持久化的 Thinking 设置,只显示一条简短提示;thought 详情仅在点击对应子项时展开,`Ctrl+O` 继续负责全局活动展开。在 full 和 compact 模式下,`Ctrl+T` 保留 Pi 的原生行为。
36
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
+
37
50
  ## 设置与渲染模式
38
51
 
39
52
  运行 `/pretty-tui` 可打开扩展设置。`Enabled` 开关会持久化控制 pi-pretty-tui 是否安装渲染补丁和内置工具 renderer。修改后会自动调用 Pi 官方的 reload 流程,无需手动执行 `/reload`。也可以直接使用 `/pretty-tui enable` 和 `/pretty-tui disable`。禁用期间设置命令仍然可用,Pi 会恢复原生渲染。
@@ -63,6 +76,8 @@ npm test
63
76
 
64
77
  ## 兼容性说明
65
78
 
79
+ 当前开发与回归测试基线为 **Pi 1.0.0**。用户气泡已适配该版本直接使用 Markdown 的布局,保留透明背景与按内容自适应的宽度。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
80
+
66
81
  工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
67
82
 
68
83
  本扩展不会修改 Pi 源文件。运行时补丁会在会话关闭时移除,但未来的 Pi 版本可能需要本扩展同步适配。
Binary file
@@ -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,
@@ -192,6 +193,16 @@ export default function prettyTui(pi: ExtensionAPI) {
192
193
  };
193
194
  const responseAnswerTexts = new Map<string, string>();
194
195
  let responseRunStartedAt: number | undefined;
196
+ let workingElapsedTimer: ReturnType<typeof setInterval> | undefined;
197
+ const stopWorkingElapsedTimer = () => {
198
+ if (workingElapsedTimer) clearInterval(workingElapsedTimer);
199
+ workingElapsedTimer = undefined;
200
+ };
201
+ const startWorkingElapsedTimer = () => {
202
+ if (workingElapsedTimer) return;
203
+ workingElapsedTimer = setInterval(() => currentTui?.requestRender?.(), 1000);
204
+ workingElapsedTimer.unref?.();
205
+ };
195
206
  let responseHasAssistantMessage = false;
196
207
  const assistantAnswerText = (message: any): string => {
197
208
  if (typeof message?.content === "string") return message.content.trim();
@@ -203,7 +214,7 @@ export default function prettyTui(pi: ExtensionAPI) {
203
214
  .trim();
204
215
  };
205
216
  const formatResponseDuration = (durationMs: number): string => {
206
- const totalSeconds = Math.max(1, Math.round(Math.max(0, durationMs) / 1000));
217
+ const totalSeconds = Math.floor(Math.max(0, durationMs) / 1000);
207
218
  if (totalSeconds < 60) return `${totalSeconds}s`;
208
219
  const totalMinutes = Math.floor(totalSeconds / 60);
209
220
  const seconds = totalSeconds % 60;
@@ -213,6 +224,11 @@ export default function prettyTui(pi: ExtensionAPI) {
213
224
  return `${hours}h ${String(minutes).padStart(2, "0")}m`;
214
225
  };
215
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
+ };
216
232
  const revealedActivityGroups = new Set<string>();
217
233
  // A local collapse must still work when Ctrl+O has expanded every group.
218
234
  const collapsedActivityGroups = new Set<string>();
@@ -226,6 +242,8 @@ export default function prettyTui(pi: ExtensionAPI) {
226
242
  component: Markdown;
227
243
  }>();
228
244
  let activityUpdateSequence = 0;
245
+ // Assigned by the interactive-mode patch below; tool events carry no `this`.
246
+ let flushDeferredCacheNotices: ((toolCallId: string) => void) | undefined;
229
247
  const assistantBoundaryKeys = new Set<string>();
230
248
  const cleanCompactToolCallIds = new Set<string>();
231
249
  const cleanGroupToolCallIds = new Map<string, string[]>();
@@ -363,7 +381,7 @@ export default function prettyTui(pi: ExtensionAPI) {
363
381
  const renderActivityCollapse = (group: ActivityGroup, width: number): string => {
364
382
  const theme = activityGroupTheme(group);
365
383
  return truncateToWidth(
366
- theme.fg("dim", collapsePrefix) + theme.fg("muted", collapseLabel),
384
+ theme.fg("dim", collapsePrefix) + theme.fg("mdLink", collapseLabel),
367
385
  Math.max(1, width),
368
386
  "",
369
387
  );
@@ -663,8 +681,13 @@ export default function prettyTui(pi: ExtensionAPI) {
663
681
  collapsedActivityGroups.clear();
664
682
  expandedThinkingMembers.clear();
665
683
  renderMode = requested;
666
- if (renderMode === "clean") refreshLatestActivityWidget();
667
- else clearLatestActivityStatus();
684
+ if (renderMode === "clean") {
685
+ if (cleanRun.active && !cleanRun.settled) startWorkingElapsedTimer();
686
+ refreshLatestActivityWidget();
687
+ } else {
688
+ stopWorkingElapsedTimer();
689
+ clearLatestActivityStatus();
690
+ }
668
691
  for (const component of cleanToolComponents.values()) component.updateDisplay?.();
669
692
  for (const component of thinkingComponents.values()) component.invalidate?.();
670
693
  try {
@@ -680,6 +703,7 @@ export default function prettyTui(pi: ExtensionAPI) {
680
703
  // Keep the settings command available while disabled, but do not install
681
704
  // render patches or override built-in tools until the next enabled reload.
682
705
  if (!activeForSession) return;
706
+ pi.on("session_shutdown", stopWorkingElapsedTimer);
683
707
 
684
708
  // Give Pi's main prompt editor a complete rounded frame. Render the native
685
709
  // editor at a two-column narrower width so cursor layout, wrapping, IME, and
@@ -690,7 +714,30 @@ export default function prettyTui(pi: ExtensionAPI) {
690
714
  const hadOwnRender = Object.prototype.hasOwnProperty.call(customEditorPrototype, "render");
691
715
  const hadOwnHandleMouse = Object.prototype.hasOwnProperty.call(customEditorPrototype, "handleMouse");
692
716
  const originalEditorRender = customEditorPrototype.render;
717
+ const originalEditorTopBorder = customEditorPrototype.renderTopBorder;
693
718
  const originalEditorHandleMouse = customEditorPrototype.handleMouse;
719
+ const patchedEditorTopBorder = function (this: any, width: number, hiddenLineCount: number): string {
720
+ if (this.tui) currentTui = this.tui;
721
+ const indicator = this.workingStatusIndicator;
722
+ if (renderMode !== "clean" || indicator?.kind !== "working" || responseRunStartedAt === undefined) {
723
+ return originalEditorTopBorder.call(this, width, hiddenLineCount);
724
+ }
725
+ const elapsed = formatResponseDuration(Date.now() - responseRunStartedAt);
726
+ const indicatorColor = (text: string) => this.borderColor(text);
727
+ const originalRenderInBorder = indicator.renderInBorder;
728
+ indicator.renderInBorder = function (availableWidth: number): string {
729
+ const status = originalRenderInBorder.call(this, availableWidth);
730
+ const suffix = ` ${elapsed}`;
731
+ return visibleWidth(status) + visibleWidth(suffix) <= availableWidth
732
+ ? status + indicatorColor(suffix)
733
+ : status;
734
+ };
735
+ try {
736
+ return originalEditorTopBorder.call(this, width, hiddenLineCount);
737
+ } finally {
738
+ indicator.renderInBorder = originalRenderInBorder;
739
+ }
740
+ };
694
741
  const patchedEditorRender = function (this: any, width: number): string[] {
695
742
  if (width < 3) return originalEditorRender.call(this, width);
696
743
 
@@ -724,11 +771,14 @@ export default function prettyTui(pi: ExtensionAPI) {
724
771
  hadOwnRender,
725
772
  hadOwnHandleMouse,
726
773
  originalRender: originalEditorRender,
774
+ originalTopBorder: originalEditorTopBorder,
727
775
  originalHandleMouse: originalEditorHandleMouse,
728
776
  patchedRender: patchedEditorRender,
777
+ patchedTopBorder: patchedEditorTopBorder,
729
778
  patchedHandleMouse: patchedEditorHandleMouse,
730
779
  };
731
780
  customEditorPrototype.render = patchedEditorRender;
781
+ customEditorPrototype.renderTopBorder = patchedEditorTopBorder;
732
782
  customEditorPrototype.handleMouse = patchedEditorHandleMouse;
733
783
 
734
784
  pi.on("session_shutdown", () => {
@@ -738,12 +788,16 @@ export default function prettyTui(pi: ExtensionAPI) {
738
788
  if (patch.hadOwnRender) customEditorPrototype.render = patch.originalRender;
739
789
  else delete customEditorPrototype.render;
740
790
  }
791
+ if (patch.patchedTopBorder === customEditorPrototype.renderTopBorder) {
792
+ customEditorPrototype.renderTopBorder = patch.originalTopBorder;
793
+ }
741
794
  if (patch.patchedHandleMouse === customEditorPrototype.handleMouse) {
742
795
  if (patch.hadOwnHandleMouse) customEditorPrototype.handleMouse = patch.originalHandleMouse;
743
796
  else delete customEditorPrototype.handleMouse;
744
797
  }
745
798
  if (
746
799
  customEditorPrototype.render === patch.originalRender &&
800
+ customEditorPrototype.renderTopBorder === patch.originalTopBorder &&
747
801
  customEditorPrototype.handleMouse === patch.originalHandleMouse
748
802
  ) {
749
803
  delete customEditorPrototype[editorFramePatchKey];
@@ -770,10 +824,16 @@ export default function prettyTui(pi: ExtensionAPI) {
770
824
  content.paddingY = 0;
771
825
  content.setBgFn?.(undefined);
772
826
  content.invalidate?.();
773
- // Pi wraps the Markdown in a full-width Box. Render the Markdown child
774
- // directly so short messages expose their natural visible width instead
775
- // 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.
776
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?.();
777
837
 
778
838
  const outputPad = Math.max(0, Number(this.outputPad) || 0);
779
839
  const markdownTheme = this.markdownTheme;
@@ -1433,6 +1493,17 @@ export default function prettyTui(pi: ExtensionAPI) {
1433
1493
  const originalShowStatus = interactiveModePrototype.showStatus;
1434
1494
  const originalShowWarning = interactiveModePrototype.showWarning;
1435
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>();
1436
1507
  const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
1437
1508
  cleanToolsExpanded = expanded;
1438
1509
  collapsedActivityGroups.clear();
@@ -1458,6 +1529,18 @@ export default function prettyTui(pi: ExtensionAPI) {
1458
1529
  // while Pi's live compaction UI appends it chronologically. Keep reloads
1459
1530
  // and transcript rebuilds consistent with that live presentation.
1460
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
+ }
1461
1544
  return originalRenderSessionEntries.call(this, orderedEntries, options);
1462
1545
  };
1463
1546
 
@@ -1542,6 +1625,163 @@ export default function prettyTui(pi: ExtensionAPI) {
1542
1625
  }
1543
1626
  };
1544
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
+
1545
1785
  interactiveModePrototype[toolsExpansionPatchKey] = {
1546
1786
  originalSetToolsExpanded,
1547
1787
  patchedSetToolsExpanded,
@@ -1559,6 +1799,12 @@ export default function prettyTui(pi: ExtensionAPI) {
1559
1799
  patchedShowWarning,
1560
1800
  originalShowError,
1561
1801
  patchedShowError,
1802
+ originalAddCacheMissNotice,
1803
+ patchedAddCacheMissNotice,
1804
+ originalMaybeShowCacheMissNotice,
1805
+ patchedMaybeShowCacheMissNotice,
1806
+ originalAddMessageToChat,
1807
+ patchedAddMessageToChat,
1562
1808
  };
1563
1809
  interactiveModePrototype.setToolsExpanded = patchedSetToolsExpanded;
1564
1810
  interactiveModePrototype.renderSessionEntries = patchedRenderSessionEntries;
@@ -1568,6 +1814,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1568
1814
  interactiveModePrototype.showStatus = patchedShowStatus;
1569
1815
  interactiveModePrototype.showWarning = patchedShowWarning;
1570
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
+ }
1571
1826
 
1572
1827
  pi.on("session_shutdown", () => {
1573
1828
  fullscreenTui = false;
@@ -1600,6 +1855,15 @@ export default function prettyTui(pi: ExtensionAPI) {
1600
1855
  if (patch.patchedShowError === interactiveModePrototype.showError) {
1601
1856
  interactiveModePrototype.showError = patch.originalShowError;
1602
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
+ }
1603
1867
  if (
1604
1868
  interactiveModePrototype.setToolsExpanded === patch.originalSetToolsExpanded &&
1605
1869
  interactiveModePrototype.renderSessionEntries === patch.originalRenderSessionEntries &&
@@ -1608,7 +1872,10 @@ export default function prettyTui(pi: ExtensionAPI) {
1608
1872
  interactiveModePrototype.toggleThinkingBlockVisibility === patch.originalToggleThinkingBlockVisibility &&
1609
1873
  interactiveModePrototype.showStatus === patch.originalShowStatus &&
1610
1874
  interactiveModePrototype.showWarning === patch.originalShowWarning &&
1611
- interactiveModePrototype.showError === patch.originalShowError
1875
+ interactiveModePrototype.showError === patch.originalShowError &&
1876
+ interactiveModePrototype.addCacheMissNotice === patch.originalAddCacheMissNotice &&
1877
+ interactiveModePrototype.maybeShowCacheMissNotice === patch.originalMaybeShowCacheMissNotice &&
1878
+ interactiveModePrototype.addMessageToChat === patch.originalAddMessageToChat
1612
1879
  ) {
1613
1880
  delete interactiveModePrototype[toolsExpansionPatchKey];
1614
1881
  }
@@ -2061,24 +2328,10 @@ export default function prettyTui(pi: ExtensionAPI) {
2061
2328
  context.state.compactToolStatus = status;
2062
2329
  };
2063
2330
 
2064
- const foregroundLuminance = (styled: string): number | undefined => {
2065
- const trueColor = /\x1b\[38;2;(\d+);(\d+);(\d+)m/.exec(styled);
2066
- if (!trueColor) return undefined;
2067
- const [, red, green, blue] = trueColor.map(Number);
2068
- return red * 0.2126 + green * 0.7152 + blue * 0.0722;
2069
- };
2070
-
2071
- const successMarkerColor = (theme: any): string => {
2072
- const textLuminance = foregroundLuminance(theme.fg("text", "M"));
2073
- return textLuminance !== undefined && textLuminance < 128
2074
- ? "success"
2075
- : "syntaxComment";
2076
- };
2077
-
2078
2331
  const callRow = (theme: any, name: string, detail: string, state: any): DisplayRow => ({
2079
2332
  prefix: () => {
2080
2333
  const status = (state.compactToolStatus ?? "running") as ToolStatus;
2081
- const color = status === "success" ? successMarkerColor(theme) : status === "error" ? "error" : "dim";
2334
+ const color = status === "success" ? "success" : status === "error" ? "error" : "dim";
2082
2335
  return theme.fg(color, "● ");
2083
2336
  },
2084
2337
  continuation: " ",
@@ -2350,7 +2603,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2350
2603
  const dotColor = component.result?.isError
2351
2604
  ? "error"
2352
2605
  : component.result && !component.isPartial
2353
- ? successMarkerColor(theme)
2606
+ ? "success"
2354
2607
  : "accent";
2355
2608
  const title = theme.fg(dotColor, "● ") +
2356
2609
  theme.fg("accent", theme.bold(label)) +
@@ -2539,7 +2792,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2539
2792
  // not one of our self-rendering specialized tools.
2540
2793
  const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName) ||
2541
2794
  this.toolDefinition?.renderShell !== "self";
2542
- const cacheable = !this.expanded && Boolean(this.result) && !this.isPartial;
2795
+ const cacheable = nestedTools.count(this.toolCallId) === 0 && !this.expanded && Boolean(this.result) && !this.isPartial;
2543
2796
  const cached = cacheable ? this[cleanChildRenderCacheKey] : undefined;
2544
2797
  let decoratedContent: string[];
2545
2798
  if (
@@ -2556,7 +2809,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2556
2809
  decoratedContent = cached.lines;
2557
2810
  } else {
2558
2811
  let contentLines: string[];
2559
- if (thirdParty) {
2812
+ if (nestedTools.count(this.toolCallId) > 0) {
2813
+ contentLines = nestedTools.render(this, childWidth, childTheme).lines;
2814
+ } else if (thirdParty) {
2560
2815
  const summaryLines = renderThirdPartyCompact(this, childWidth, childTheme);
2561
2816
  if (this.expanded) {
2562
2817
  const detailPrefix = " │ ";
@@ -2640,6 +2895,24 @@ export default function prettyTui(pi: ExtensionAPI) {
2640
2895
  return { handled: true };
2641
2896
  }
2642
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
+
2643
2916
  // Self-rendering tools reserve y=0 for Pi's leading spacer. Clean mode
2644
2917
  // replaces that spacer with the compact header for non-first members.
2645
2918
  const thirdPartyHeaderY = position.first ? summaryHeight + 1 : 0;
@@ -2769,6 +3042,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2769
3042
  _failed: number,
2770
3043
  thoughtCount: number,
2771
3044
  activity = "done",
3045
+ nestedCount = 0,
2772
3046
  ): string => {
2773
3047
  const rawActivityText = typeof activity === "string" ? activity : "done";
2774
3048
  const normalizedActivityText = rawActivityText.trim();
@@ -2784,7 +3058,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2784
3058
  const activityLabel = count > 0 && activityText !== "done" && activityText.trim()
2785
3059
  ? activityText
2786
3060
  : "";
2787
- return [countLabel, thoughtLabel, activityLabel].filter(Boolean).join(" · ");
3061
+ return [countLabel, nestedCount ? `${nestedCount} nested ${nestedCount === 1 ? "call" : "calls"}` : "", thoughtLabel, activityLabel].filter(Boolean).join(" · ");
2788
3062
  };
2789
3063
 
2790
3064
  const summaryRow = (
@@ -2794,11 +3068,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2794
3068
  thoughtCount: number,
2795
3069
  activity: DisplayValue = "done",
2796
3070
  collapsedDone = false,
3071
+ nestedCount = 0,
2797
3072
  ): DisplayRow => ({
2798
3073
  prefix: () => {
2799
3074
  const currentActivity = typeof activity === "function" ? activity() : activity;
2800
3075
  const color = currentActivity === "done"
2801
- ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
3076
+ ? collapsedDone ? "thinkingText" : "success"
2802
3077
  : "accent";
2803
3078
  return theme.fg(color, "● ");
2804
3079
  },
@@ -2807,12 +3082,12 @@ export default function prettyTui(pi: ExtensionAPI) {
2807
3082
  const currentActivity = typeof activity === "function" ? activity() : activity;
2808
3083
  const label = currentActivity === "done" ? "Done" : "Running";
2809
3084
  const color = label === "Done"
2810
- ? collapsedDone ? "thinkingText" : successMarkerColor(theme)
3085
+ ? collapsedDone ? "thinkingText" : "success"
2811
3086
  : "accent";
2812
3087
  const detailColor = label === "Done" && collapsedDone ? "thinkingText" : "text";
2813
3088
  return theme.fg(color, theme.bold(label)) +
2814
3089
  theme.fg("dim", "(") +
2815
- theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity)) +
3090
+ theme.fg(detailColor, summaryText(count, failed, thoughtCount, currentActivity, nestedCount)) +
2816
3091
  theme.fg("dim", ")");
2817
3092
  },
2818
3093
  });
@@ -2862,6 +3137,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2862
3137
  const owner = activityGroupOwner(group);
2863
3138
  const settled = owner ? settledSummaries.get(owner) : undefined;
2864
3139
  const toolCount = group.toolCallIds.length;
3140
+ const nestedCount = group.toolCallIds.reduce((count, id) => count + nestedTools.count(id), 0);
2865
3141
  const live = () => cleanRun.activeToolName ?? currentCleanActivity();
2866
3142
  if (settled) {
2867
3143
  return fit(block([summaryRow(
@@ -2871,6 +3147,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2871
3147
  Math.max(settled.thoughtCount, group.thoughtCount),
2872
3148
  settled.activity,
2873
3149
  collapsedDone,
3150
+ nestedCount,
2874
3151
  )]).render(width));
2875
3152
  }
2876
3153
  if (owner && !cleanRun.settled) {
@@ -2882,6 +3159,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2882
3159
  group.thoughtCount,
2883
3160
  live,
2884
3161
  collapsedDone,
3162
+ nestedCount,
2885
3163
  )]).render(width));
2886
3164
  }
2887
3165
  if (!owner && !activityTimeline.isSettled(group.id)) {
@@ -2893,6 +3171,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2893
3171
  group.thoughtCount,
2894
3172
  "thinking",
2895
3173
  collapsedDone,
3174
+ nestedCount,
2896
3175
  )]).render(width));
2897
3176
  }
2898
3177
  // Finished group with no durable summary: a completed Thought-only turn, or
@@ -2904,6 +3183,7 @@ export default function prettyTui(pi: ExtensionAPI) {
2904
3183
  group.thoughtCount,
2905
3184
  "done",
2906
3185
  collapsedDone,
3186
+ nestedCount,
2907
3187
  )]).render(width));
2908
3188
  };
2909
3189
 
@@ -3095,12 +3375,14 @@ export default function prettyTui(pi: ExtensionAPI) {
3095
3375
  );
3096
3376
 
3097
3377
  const restoreCleanSession = (ctx: any) => {
3378
+ stopWorkingElapsedTimer();
3098
3379
  currentExtensionUi = ctx.ui;
3099
3380
  clearLatestActivityStatus();
3100
3381
  cleanToolsExpanded = ctx.ui.getToolsExpanded();
3101
3382
  settledSummaries.clear();
3102
3383
  legacySummaryLastToolCallIds.clear();
3103
3384
  activityTimeline.clear();
3385
+ nestedTools.clear();
3104
3386
  responseAnswerTexts.clear();
3105
3387
  responseRunStartedAt = undefined;
3106
3388
  responseHasAssistantMessage = false;
@@ -3260,6 +3542,10 @@ export default function prettyTui(pi: ExtensionAPI) {
3260
3542
  cleanContextCompacted = modelContextEntries[0]?.type === "compaction";
3261
3543
  const contextEntries = orderContextEntriesForTranscript(modelContextEntries);
3262
3544
  for (const entry of contextEntries) {
3545
+ if (entry.type === "custom" && entry.customType === NESTED_SNAPSHOT_ENTRY) {
3546
+ nestedTools.restore(entry.data);
3547
+ continue;
3548
+ }
3263
3549
  if (entry.type === "compaction") {
3264
3550
  finishGroup();
3265
3551
  lastFinishedGroup = undefined;
@@ -3344,6 +3630,10 @@ export default function prettyTui(pi: ExtensionAPI) {
3344
3630
  continue;
3345
3631
  }
3346
3632
 
3633
+ if (message.role === "toolResult") {
3634
+ nestedTools.absorb(message.toolCallId, message.nestedCalls ?? message.details?.calls, !message.details?.callsTruncated);
3635
+ }
3636
+
3347
3637
  if (
3348
3638
  message.role === "toolResult" &&
3349
3639
  toolCalls.has(message.toolCallId) &&
@@ -3426,6 +3716,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3426
3716
  if (toolCalls.length === 0) return false;
3427
3717
  for (const toolCall of toolCalls) {
3428
3718
  activityTimeline.addTool(toolCall.id, toolCall.name ?? "tool");
3719
+ flushDeferredCacheNotices?.(toolCall.id);
3429
3720
  if (!cleanRun.currentToolCallIds.includes(toolCall.id)) {
3430
3721
  cleanRun.currentToolCallIds.push(toolCall.id);
3431
3722
  }
@@ -3519,9 +3810,16 @@ export default function prettyTui(pi: ExtensionAPI) {
3519
3810
  cleanRun.activeToolCallId = undefined;
3520
3811
  cleanRun.activeToolName = undefined;
3521
3812
  cleanRun.activity = "thinking";
3813
+ if (renderMode === "clean") startWorkingElapsedTimer();
3522
3814
  });
3523
3815
  pi.on("tool_execution_start", (event) => {
3816
+ if ((event as any).parentToolCallId) {
3817
+ nestedTools.start(event);
3818
+ refreshNestedTools(event.toolCallId);
3819
+ return;
3820
+ }
3524
3821
  activityTimeline.addTool(event.toolCallId, event.toolName);
3822
+ flushDeferredCacheNotices?.(event.toolCallId);
3525
3823
  if (!cleanRun.currentToolCallIds.includes(event.toolCallId)) {
3526
3824
  cleanRun.currentToolCallIds.push(event.toolCallId);
3527
3825
  }
@@ -3533,7 +3831,25 @@ export default function prettyTui(pi: ExtensionAPI) {
3533
3831
  cleanRun.activeToolCallId = event.toolCallId;
3534
3832
  beginToolActivity(event.toolCallId, displayName, true);
3535
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
+ });
3536
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);
3537
3853
  const activityName = cleanRun.activeToolCallId === event.toolCallId
3538
3854
  ? cleanRun.activeToolName ?? cleanToolNames.get(event.toolCallId) ?? event.toolName
3539
3855
  : cleanToolNames.get(event.toolCallId) ?? event.toolName;
@@ -3568,7 +3884,13 @@ export default function prettyTui(pi: ExtensionAPI) {
3568
3884
  cleanRun.activity = "thinking";
3569
3885
  });
3570
3886
  pi.on("agent_settled", (_event, ctx) => {
3887
+ stopWorkingElapsedTimer();
3571
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
+ }
3572
3894
 
3573
3895
  clearPendingToolActivities();
3574
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.3",
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"
@@ -52,6 +52,6 @@
52
52
  "extensions": [
53
53
  "./extensions/index.ts"
54
54
  ],
55
- "image": "https://unpkg.com/pi-pretty-tui@0.2.0/assets/screenshot.png"
55
+ "image": "https://raw.githubusercontent.com/ykn0309/pi-pretty-tui/main/assets/screenshot.png"
56
56
  }
57
57
  }