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 +16 -2
- package/README.zh-CN.md +16 -2
- package/extensions/activity-timeline.ts +56 -0
- package/extensions/index.ts +298 -25
- package/extensions/nested-tools.ts +244 -0
- package/package.json +3 -3
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
|
|
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.
|
|
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(...)
|
|
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`
|
|
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
|
package/extensions/index.ts
CHANGED
|
@@ -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("
|
|
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
|
|
820
|
-
//
|
|
821
|
-
//
|
|
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" ?
|
|
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
|
-
?
|
|
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 (
|
|
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.
|
|
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.
|
|
43
|
-
"@earendil-works/pi-tui": "0.
|
|
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"
|