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 +17 -2
- package/README.zh-CN.md +17 -2
- package/assets/screenshot.png +0 -0
- package/extensions/activity-timeline.ts +56 -0
- package/extensions/index.ts +352 -30
- package/extensions/nested-tools.ts +244 -0
- package/package.json +4 -4
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
|
|
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.
|
|
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(...)
|
|
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`
|
|
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 版本可能需要本扩展同步适配。
|
package/assets/screenshot.png
CHANGED
|
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
|
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,
|
|
@@ -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.
|
|
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("
|
|
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")
|
|
667
|
-
|
|
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
|
|
774
|
-
//
|
|
775
|
-
//
|
|
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" ?
|
|
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
|
-
?
|
|
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 (
|
|
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" :
|
|
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" :
|
|
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
|
+
"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"
|
|
@@ -52,6 +52,6 @@
|
|
|
52
52
|
"extensions": [
|
|
53
53
|
"./extensions/index.ts"
|
|
54
54
|
],
|
|
55
|
-
"image": "https://
|
|
55
|
+
"image": "https://raw.githubusercontent.com/ykn0309/pi-pretty-tui/main/assets/screenshot.png"
|
|
56
56
|
}
|
|
57
57
|
}
|