pi-pretty-tui 0.2.1 → 0.2.3
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 +5 -4
- package/README.zh-CN.md +5 -4
- package/extensions/index.ts +346 -144
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,8 +8,9 @@ A beautiful, polished TUI for the [Pi coding agent](https://pi.dev/).
|
|
|
8
8
|
|
|
9
9
|
- **Collapsible activity timeline:** instead of filling the conversation with individual calls, thoughts, tools, and ordinary `info` notifications share one chronological activity stream. Work appears beneath a single `Running(...)` row and settles into a quiet `Done(...)` row. Parent rows count only tool calls and thoughts; expanding one reveals every ordered member with summaries retained above nested details. Thoughts and tools are peers: a turn that calls no tool still gets its own `Running(1 thought)` / `Done(1 thought)` parent row with the Thought as its child, so thinking is never dropped.
|
|
10
10
|
- **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
|
+
- **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.
|
|
11
12
|
- **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.
|
|
12
|
-
- **A cohesive visual finish:**
|
|
13
|
+
- **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.
|
|
13
14
|
|
|
14
15
|
Pi's built-in `read`, `bash`, `edit`, `write`, `grep`, `find`, and `ls` tools receive purpose-built compact views. Every third-party tool automatically receives a consistent compact row; opening it preserves its native renderer while removing conflicting terminal background colors. User and steering messages, compaction summaries, visible assistant text, warnings, errors, aborted/truncated responses, and displayed custom messages with a registered renderer create hard group boundaries. Pi's cursor, IME, autocomplete, mouse interaction, and tool execution behavior remain intact.
|
|
15
16
|
|
|
@@ -25,7 +26,7 @@ Pi may warn that built-in tools are being overridden. This is expected: pi-prett
|
|
|
25
26
|
|
|
26
27
|
## Working with tool calls
|
|
27
28
|
|
|
28
|
-
Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary,
|
|
29
|
+
Click a `Running(...)` or `Done(...)` row to keep it as a parent and reveal tools, completed `● thought` entries, and extension updates beneath it. Click an individual tool or thought to reveal nested details without replacing its summary. The last item in each expanded tree, `└─ [↑ Collapse]`, collapses just that group without scrolling back to its parent; clicking the parent also works. Press `Ctrl+O` at any time to use Pi's global expansion control for all tool output and thinking content. The end-of-tree action still collapses only its own group after global expansion. Third-party replacements for `grep` and `find` (such as FFF) use compact tree rows too, with their native details available when expanded. Tool names use each definition's `label` verbatim, falling back to its raw `name`; pi-pretty-tui does not rename or title-case them.
|
|
29
30
|
|
|
30
31
|
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.
|
|
31
32
|
|
|
@@ -58,11 +59,11 @@ npm install
|
|
|
58
59
|
npm test
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
The suite covers live and restored activity timelines, retries and terminal states, user/steering/compaction/native-renderer boundaries, ten-second UI-status replacement, stale-timer and flash cleanup on reload and shutdown, standalone severity styling, built-in and third-party tools, expandable thinking, parallel completion ownership, orphaned calls, duplicate-summary prevention, complete prototype-patch restoration, and fullscreen code-block copy hit regions. GitHub Actions runs the same checks on every push and pull request.
|
|
62
|
+
The suite covers live and restored activity timelines, retries and terminal states, user/steering/compaction/native-renderer boundaries, ten-second UI-status replacement, stale-timer and flash cleanup on reload and shutdown, standalone severity styling, built-in and third-party tools, expandable thinking, parallel completion ownership, orphaned calls, duplicate-summary prevention, durable response timing and answer-only copy controls, adaptive user chat bubbles, complete prototype-patch restoration, and fullscreen code-block copy hit regions. GitHub Actions runs the same checks on every push and pull request.
|
|
62
63
|
|
|
63
64
|
## Compatibility notice
|
|
64
65
|
|
|
65
|
-
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.
|
|
66
|
+
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.
|
|
66
67
|
|
|
67
68
|
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.
|
|
68
69
|
|
package/README.zh-CN.md
CHANGED
|
@@ -7,9 +7,10 @@
|
|
|
7
7
|
## 功能特性
|
|
8
8
|
|
|
9
9
|
- **可折叠的活动时间线:** 不再让大量独立调用填满对话。思考、工具和普通 `info` 通知会进入同一条按真实时间排序的活动流;工作进行时显示为 `Running(...)`,结束后变为低调的 `Done(...)`。父级只统计工具调用和思考数量;展开后可查看所有有序成员,成员摘要在展开嵌套详情后仍然保留。思考与工具地位相同:没有调用任何工具的那一轮同样会有自己的 `Running(1 thought)` / `Done(1 thought)` 父行,思考作为其子级,内容不会被丢弃。
|
|
10
|
+
- **可持久化的回答 footer:** 整轮 Agent 工作真正 settled 后(包括工具、自动重试和 compaction),最终回答底部会显示一条低调的灰色分隔线,工作时长与 fullscreen 模式下的 `[Copy]` 控件都嵌在线中。`[Copy]` 只复制最终可见回答,不包含 thought、工具、状态或 footer;reload 和 session tree 切换后仍会保留。
|
|
10
11
|
- **临时 UI 状态:** 编辑器上方的一行只显示 Pi 直接产生的 UI 操作反馈,例如 Thinking 或工具输出可见性变化。新状态会替换旧状态,并在十秒后自动消失,不进入 transcript 或 session。
|
|
11
12
|
- **按需查看有效信息:** 针对不同工具设计的摘要,会简洁呈现路径、命令、结果、diff、文件预览和实时 shell 输出,同时保留查看原始完整内容的能力。
|
|
12
|
-
- **统一、精致的视觉体验:**
|
|
13
|
+
- **统一、精致的视觉体验:** 透明、右对齐的用户聊天气泡会根据短消息自适应宽度,长消息则在终端宽度的 75% 处换行;气泡使用 accent 浅蓝色圆角边框,并移除了多余的 `User` 标注。配合圆角输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
|
|
13
14
|
|
|
14
15
|
Pi 内置的 `read`、`bash`、`edit`、`write`、`grep`、`find` 和 `ls` 工具使用专门设计的紧凑视图;所有第三方工具会自动获得统一的紧凑行,单独展开时继续使用原生 renderer,但会移除与整体冲突的终端背景色。用户与 steer 消息、compaction 摘要、可见 assistant 正文、warning、error、aborted/length 响应,以及注册了专用 renderer 的 displayed custom message 都会形成硬分组边界。同时不会影响 Pi 原有的光标、输入法、自动补全、鼠标交互和工具执行行为。
|
|
15
16
|
|
|
@@ -25,7 +26,7 @@ Pi 可能会提示内置工具被覆盖。这是正常现象:pi-pretty-tui 会
|
|
|
25
26
|
|
|
26
27
|
## 使用工具调用
|
|
27
28
|
|
|
28
|
-
点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought`
|
|
29
|
+
点击 `Running(...)` 或 `Done(...)` 行,可以保留父级并展开其下方的工具、已完成的 `● thought` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情。每棵展开的树末尾都有 `└─ [↑ Collapse]`,无需滚回父级即可只折叠当前分组;再次点击父级也可以折叠。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏全部工具输出和思考内容;全局展开后,树尾操作仍只折叠本组。第三方替换的 `grep`、`find`(如 FFF)也使用紧凑的树节点,展开子项时仍可查看原生详情。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
|
|
29
30
|
|
|
30
31
|
扩展调用 `ctx.ui.notify(message, "info")`(或省略 type)时,如果当前已有 thought/tool 活动组,通知会按真实时间顺序加入该组;如果没有现有工作,则独立显示,并且不会附着到未来的工具组。warning 和 error 保持独立,分别使用黄色 `⚠` 和红色 `✕`,并形成硬边界。工具失败继续归属于对应工具详情。上述扩展通知和错误都不会进入临时 UI 状态栏。
|
|
31
32
|
|
|
@@ -58,11 +59,11 @@ npm install
|
|
|
58
59
|
npm test
|
|
59
60
|
```
|
|
60
61
|
|
|
61
|
-
测试覆盖实时与历史恢复的活动时间线、自动重试与终止状态、用户/steer/compaction/原生 renderer 边界、十秒 UI 状态替换、reload 与 shutdown 时的旧 timer 与状态清理、独立严重级别样式、内置与第三方工具、可展开思考、并行工具完成归属、孤立 tool call
|
|
62
|
+
测试覆盖实时与历史恢复的活动时间线、自动重试与终止状态、用户/steer/compaction/原生 renderer 边界、十秒 UI 状态替换、reload 与 shutdown 时的旧 timer 与状态清理、独立严重级别样式、内置与第三方工具、可展开思考、并行工具完成归属、孤立 tool call、重复摘要防护、可持久化的工作时长与仅回答复制控件、自适应用户聊天气泡、原型补丁的完整恢复,以及 fullscreen 代码块复制的点击区域。GitHub Actions 会在每次 push 和 pull request 时运行相同检查。
|
|
62
63
|
|
|
63
64
|
## 兼容性说明
|
|
64
65
|
|
|
65
|
-
工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi
|
|
66
|
+
工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
|
|
66
67
|
|
|
67
68
|
本扩展不会修改 Pi 源文件。运行时补丁会在会话关闭时移除,但未来的 Pi 版本可能需要本扩展同步适配。
|
|
68
69
|
|
package/extensions/index.ts
CHANGED
|
@@ -43,6 +43,7 @@ import {
|
|
|
43
43
|
type PrettyTuiMode = "full" | "compact" | "clean";
|
|
44
44
|
const CLEAN_TOOL_ACTIVITY_MIN_MS = 1000;
|
|
45
45
|
const TRANSIENT_UI_STATUS_MS = 10_000;
|
|
46
|
+
const RESPONSE_FOOTER_ENTRY_TYPE = "pretty-tui-response-footer";
|
|
46
47
|
const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
47
48
|
|
|
48
49
|
const stripAnsiBackgrounds = (value: string): string =>
|
|
@@ -168,8 +169,53 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
168
169
|
currentTui?.requestRender?.();
|
|
169
170
|
}, TRANSIENT_UI_STATUS_MS);
|
|
170
171
|
};
|
|
172
|
+
const copyTranscriptText = (text: string) => {
|
|
173
|
+
// Code-block copy must preserve leading/trailing whitespace and indentation.
|
|
174
|
+
// Answers are normalized separately by assistantAnswerText().
|
|
175
|
+
if (!text.trim()) return;
|
|
176
|
+
const copyText = text;
|
|
177
|
+
if (typeof currentTui?.copyTextToClipboard === "function") {
|
|
178
|
+
// Fullscreen uses Pi's native clipboard path and native flash feedback.
|
|
179
|
+
void Promise.resolve(currentTui.copyTextToClipboard(copyText)).catch(() => {
|
|
180
|
+
currentTui?.flash?.("Copy failed");
|
|
181
|
+
});
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
void copyToClipboard(copyText)
|
|
185
|
+
.then(() => setLatestActivityStatus({ title: "Copied!", severity: "success" }))
|
|
186
|
+
.catch(() => setLatestActivityStatus({ title: "Copy failed", severity: "error" }));
|
|
187
|
+
};
|
|
188
|
+
type ResponseFooterData = {
|
|
189
|
+
answerEntryId?: string;
|
|
190
|
+
durationMs: number;
|
|
191
|
+
outcome: "completed" | "stopped";
|
|
192
|
+
};
|
|
193
|
+
const responseAnswerTexts = new Map<string, string>();
|
|
194
|
+
let responseRunStartedAt: number | undefined;
|
|
195
|
+
let responseHasAssistantMessage = false;
|
|
196
|
+
const assistantAnswerText = (message: any): string => {
|
|
197
|
+
if (typeof message?.content === "string") return message.content.trim();
|
|
198
|
+
if (!Array.isArray(message?.content)) return "";
|
|
199
|
+
return message.content
|
|
200
|
+
.filter((item: any) => item?.type === "text" && typeof item.text === "string" && item.text.trim())
|
|
201
|
+
.map((item: any) => item.text.trim())
|
|
202
|
+
.join("\n\n")
|
|
203
|
+
.trim();
|
|
204
|
+
};
|
|
205
|
+
const formatResponseDuration = (durationMs: number): string => {
|
|
206
|
+
const totalSeconds = Math.max(1, Math.round(Math.max(0, durationMs) / 1000));
|
|
207
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
208
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
209
|
+
const seconds = totalSeconds % 60;
|
|
210
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${seconds}s`;
|
|
211
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
212
|
+
const minutes = totalMinutes % 60;
|
|
213
|
+
return `${hours}h ${String(minutes).padStart(2, "0")}m`;
|
|
214
|
+
};
|
|
171
215
|
const activityTimeline = new ActivityTimeline();
|
|
172
216
|
const revealedActivityGroups = new Set<string>();
|
|
217
|
+
// A local collapse must still work when Ctrl+O has expanded every group.
|
|
218
|
+
const collapsedActivityGroups = new Set<string>();
|
|
173
219
|
const expandedThinkingMembers = new Set<string>();
|
|
174
220
|
const thinkingComponents = new Map<string, any>();
|
|
175
221
|
const activityFallbackThemes = new Map<string, any>();
|
|
@@ -298,7 +344,8 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
298
344
|
};
|
|
299
345
|
|
|
300
346
|
const activityGroupRevealed = (group: ActivityGroup): boolean =>
|
|
301
|
-
|
|
347
|
+
!collapsedActivityGroups.has(group.id) &&
|
|
348
|
+
(cleanToolsExpanded || revealedActivityGroups.has(group.id));
|
|
302
349
|
|
|
303
350
|
// A group is revealable once it owns anything renderable beneath its parent
|
|
304
351
|
// row. Thinking counts on its own: a tool-free turn still gets a parent row
|
|
@@ -311,6 +358,67 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
311
358
|
last: group.members[group.members.length - 1]?.id === member.id,
|
|
312
359
|
});
|
|
313
360
|
|
|
361
|
+
const collapseLabel = "[↑ Collapse]";
|
|
362
|
+
const collapsePrefix = " └─ ";
|
|
363
|
+
const renderActivityCollapse = (group: ActivityGroup, width: number): string => {
|
|
364
|
+
const theme = activityGroupTheme(group);
|
|
365
|
+
return truncateToWidth(
|
|
366
|
+
theme.fg("dim", collapsePrefix) + theme.fg("muted", collapseLabel),
|
|
367
|
+
Math.max(1, width),
|
|
368
|
+
"",
|
|
369
|
+
);
|
|
370
|
+
};
|
|
371
|
+
const withActivityCollapse = (
|
|
372
|
+
group: ActivityGroup,
|
|
373
|
+
member: ActivityMember,
|
|
374
|
+
width: number,
|
|
375
|
+
lines: string[],
|
|
376
|
+
): string[] => activityMemberPosition(group, member).last && activityGroupRevealed(group)
|
|
377
|
+
? [...lines, renderActivityCollapse(group, width)]
|
|
378
|
+
: lines;
|
|
379
|
+
const activityCollapseClicked = (
|
|
380
|
+
group: ActivityGroup,
|
|
381
|
+
member: ActivityMember,
|
|
382
|
+
event: any,
|
|
383
|
+
row: number,
|
|
384
|
+
): boolean => activityGroupRevealed(group) &&
|
|
385
|
+
activityMemberPosition(group, member).last &&
|
|
386
|
+
event.type === "click" && event.button === "left" && event.y === row &&
|
|
387
|
+
event.x >= visibleWidth(collapsePrefix) &&
|
|
388
|
+
event.x < Math.min(event.width, visibleWidth(collapsePrefix + collapseLabel));
|
|
389
|
+
|
|
390
|
+
const refreshActivityGroup = (group: ActivityGroup): void => {
|
|
391
|
+
for (const child of group.members) {
|
|
392
|
+
if (child.kind === "tool" && child.toolCallId) {
|
|
393
|
+
cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
|
|
394
|
+
} else if (child.kind === "thinking" && child.messageKey) {
|
|
395
|
+
thinkingComponents.get(child.messageKey)?.invalidate?.();
|
|
396
|
+
} else if (child.kind === "update") {
|
|
397
|
+
activityUpdateComponents.get(child.id)?.invalidate?.();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
const revealActivityGroup = (group: ActivityGroup, ui?: any): void => {
|
|
402
|
+
collapsedActivityGroups.delete(group.id);
|
|
403
|
+
revealedActivityGroups.add(group.id);
|
|
404
|
+
for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
|
|
405
|
+
refreshActivityGroup(group);
|
|
406
|
+
(ui ?? currentTui)?.requestRender?.();
|
|
407
|
+
};
|
|
408
|
+
const collapseActivityGroup = (group: ActivityGroup, ui?: any): void => {
|
|
409
|
+
revealedActivityGroups.delete(group.id);
|
|
410
|
+
if (cleanToolsExpanded) collapsedActivityGroups.add(group.id);
|
|
411
|
+
else collapsedActivityGroups.delete(group.id);
|
|
412
|
+
for (const toolCallId of group.toolCallIds) {
|
|
413
|
+
cleanCompactToolCallIds.delete(toolCallId);
|
|
414
|
+
const component = cleanToolComponents.get(toolCallId);
|
|
415
|
+
if (component?.expanded) component.setExpanded(false);
|
|
416
|
+
}
|
|
417
|
+
for (const child of group.members) expandedThinkingMembers.delete(child.id);
|
|
418
|
+
refreshActivityGroup(group);
|
|
419
|
+
(ui ?? currentTui)?.requestRender?.();
|
|
420
|
+
};
|
|
421
|
+
|
|
314
422
|
const activityTreeStyle = (
|
|
315
423
|
group: ActivityGroup,
|
|
316
424
|
member: ActivityMember,
|
|
@@ -318,8 +426,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
318
426
|
memberTheme?: any,
|
|
319
427
|
) => {
|
|
320
428
|
const { last } = activityMemberPosition(group, member);
|
|
321
|
-
|
|
322
|
-
const
|
|
429
|
+
// While expanded, the collapse action is the final sibling in the tree.
|
|
430
|
+
const hasFollowingCollapse = last && activityGroupRevealed(group);
|
|
431
|
+
const rawPrefix = last && !hasFollowingCollapse ? " └─ " : " ├─ ";
|
|
432
|
+
const rawContinuation = last && !hasFollowingCollapse ? " " : " │ ";
|
|
323
433
|
const prefixWidth = visibleWidth(rawPrefix);
|
|
324
434
|
if (width <= prefixWidth) {
|
|
325
435
|
return { prefix: "", continuation: "", childWidth: Math.max(1, width), prefixWidth: 0 };
|
|
@@ -406,9 +516,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
406
516
|
truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
|
|
407
517
|
);
|
|
408
518
|
const position = activityMemberPosition(group, member);
|
|
409
|
-
return
|
|
410
|
-
|
|
411
|
-
: decorated
|
|
519
|
+
return withActivityCollapse(
|
|
520
|
+
group, member, width,
|
|
521
|
+
position.first ? ["", ...renderActivityGroupSummary(group, width), ...decorated] : decorated,
|
|
522
|
+
);
|
|
412
523
|
};
|
|
413
524
|
|
|
414
525
|
const renderPendingActivityUpdate = (member: ActivityMember, width: number): string[] => {
|
|
@@ -451,29 +562,16 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
451
562
|
const position = activityMemberPosition(group, member);
|
|
452
563
|
if (!activityGroupRevealed(group)) {
|
|
453
564
|
if (!position.first) return undefined;
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
} else {
|
|
457
|
-
const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
|
|
458
|
-
if (!position.first || event.y <= 0 || event.y > summaryHeight) return undefined;
|
|
459
|
-
revealedActivityGroups.delete(group.id);
|
|
460
|
-
for (const toolCallId of group.toolCallIds) {
|
|
461
|
-
cleanCompactToolCallIds.delete(toolCallId);
|
|
462
|
-
const component = cleanToolComponents.get(toolCallId);
|
|
463
|
-
if (component?.expanded) component.setExpanded(false);
|
|
464
|
-
}
|
|
465
|
-
for (const child of group.members) expandedThinkingMembers.delete(child.id);
|
|
466
|
-
}
|
|
467
|
-
for (const child of group.members) {
|
|
468
|
-
if (child.kind === "tool" && child.toolCallId) {
|
|
469
|
-
cleanToolComponents.get(child.toolCallId)?.updateDisplay?.();
|
|
470
|
-
} else if (child.kind === "thinking" && child.messageKey) {
|
|
471
|
-
thinkingComponents.get(child.messageKey)?.invalidate?.();
|
|
472
|
-
} else if (child.kind === "update") {
|
|
473
|
-
activityUpdateComponents.get(child.id)?.invalidate?.();
|
|
474
|
-
}
|
|
565
|
+
revealActivityGroup(group);
|
|
566
|
+
return { handled: true };
|
|
475
567
|
}
|
|
476
|
-
|
|
568
|
+
const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
|
|
569
|
+
const onSummary = position.first && event.y > 0 && event.y <= summaryHeight;
|
|
570
|
+
const onCollapse = position.last && activityCollapseClicked(
|
|
571
|
+
group, member, event, renderActivityUpdate(group, member, event.width).length - 1,
|
|
572
|
+
);
|
|
573
|
+
if (!onSummary && !onCollapse) return undefined;
|
|
574
|
+
collapseActivityGroup(group);
|
|
477
575
|
return { handled: true };
|
|
478
576
|
};
|
|
479
577
|
|
|
@@ -562,6 +660,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
562
660
|
|
|
563
661
|
cleanCompactToolCallIds.clear();
|
|
564
662
|
revealedActivityGroups.clear();
|
|
663
|
+
collapsedActivityGroups.clear();
|
|
565
664
|
expandedThinkingMembers.clear();
|
|
566
665
|
renderMode = requested;
|
|
567
666
|
if (renderMode === "clean") refreshLatestActivityWidget();
|
|
@@ -652,8 +751,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
652
751
|
});
|
|
653
752
|
}
|
|
654
753
|
|
|
655
|
-
// Render user prompts as
|
|
656
|
-
//
|
|
754
|
+
// Render user prompts as right-aligned, content-sized chat bubbles while
|
|
755
|
+
// preserving Pi's Markdown component, wrapping, output padding, and OSC 133
|
|
756
|
+
// zones. Long messages stop at 75% of the terminal width; narrow terminals
|
|
757
|
+
// trade away the left gap before sacrificing readability.
|
|
657
758
|
const userMessagePrototype = UserMessageComponent.prototype as any;
|
|
658
759
|
const userMessagePatchKey = Symbol.for("pretty-tui.user-message-frame");
|
|
659
760
|
if (!userMessagePrototype[userMessagePatchKey]) {
|
|
@@ -663,66 +764,118 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
663
764
|
const content = this.children?.[0] as any;
|
|
664
765
|
if (!content) return;
|
|
665
766
|
|
|
666
|
-
//
|
|
667
|
-
|
|
767
|
+
// Remove Pi's full-width user-message background. The Markdown keeps its
|
|
768
|
+
// native foreground styling, while the surrounding bubble stays transparent.
|
|
668
769
|
content.paddingX = 0;
|
|
669
770
|
content.paddingY = 0;
|
|
670
771
|
content.setBgFn?.(undefined);
|
|
671
772
|
content.invalidate?.();
|
|
773
|
+
// Pi wraps the Markdown in a full-width Box. Render the Markdown child
|
|
774
|
+
// directly so short messages expose their natural visible width instead
|
|
775
|
+
// of inheriting the Box's width-filling padding.
|
|
776
|
+
const markdown = content.children?.[0] ?? content;
|
|
672
777
|
|
|
673
778
|
const outputPad = Math.max(0, Number(this.outputPad) || 0);
|
|
674
779
|
const markdownTheme = this.markdownTheme;
|
|
675
|
-
|
|
780
|
+
// Markdown deliberately pads every rendered line to the requested width.
|
|
781
|
+
// Remove only that trailing layout padding before measuring or framing;
|
|
782
|
+
// ANSI styling and the visible content remain untouched.
|
|
783
|
+
const renderMarkdown = (width: number): string[] =>
|
|
784
|
+
withTranscriptMarkdown(() => markdown.render(width))
|
|
785
|
+
.map((line: string) => line.replace(/ +$/u, ""));
|
|
786
|
+
const layout = (width: number) => {
|
|
787
|
+
const terminalWidth = Math.max(1, width);
|
|
788
|
+
const rightPad = Math.min(outputPad, Math.max(0, terminalWidth - 1));
|
|
789
|
+
const availableWidth = Math.max(1, terminalWidth - rightPad);
|
|
790
|
+
if (availableWidth < 4) {
|
|
791
|
+
return {
|
|
792
|
+
framed: false,
|
|
793
|
+
rightPad,
|
|
794
|
+
leftPad: 0,
|
|
795
|
+
frameWidth: availableWidth,
|
|
796
|
+
contentWidth: availableWidth,
|
|
797
|
+
body: renderMarkdown(availableWidth),
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
const preferredMaximum = Math.max(4, Math.floor(terminalWidth * 0.75));
|
|
802
|
+
// Keep at least one blank column on the left whenever a framed bubble
|
|
803
|
+
// fits, even after a narrow terminal relaxes the usual 75% maximum.
|
|
804
|
+
const bubbleAvailable = availableWidth >= 5 ? availableWidth - 1 : availableWidth;
|
|
805
|
+
const narrowReadableMinimum = Math.min(bubbleAvailable, 24);
|
|
806
|
+
const frameMaximum = Math.min(
|
|
807
|
+
bubbleAvailable,
|
|
808
|
+
Math.max(preferredMaximum, narrowReadableMinimum),
|
|
809
|
+
);
|
|
810
|
+
const measuringWidth = Math.max(1, frameMaximum - 4);
|
|
811
|
+
const measuredBody = renderMarkdown(measuringWidth);
|
|
812
|
+
const naturalContentWidth = Math.max(
|
|
813
|
+
1,
|
|
814
|
+
...measuredBody.map((line: string) => visibleWidth(line)),
|
|
815
|
+
);
|
|
816
|
+
const frameWidth = Math.min(frameMaximum, naturalContentWidth + 4);
|
|
817
|
+
const contentWidth = Math.max(1, frameWidth - 4);
|
|
818
|
+
const body = contentWidth === measuringWidth
|
|
819
|
+
? measuredBody
|
|
820
|
+
: renderMarkdown(contentWidth);
|
|
821
|
+
return {
|
|
822
|
+
framed: true,
|
|
823
|
+
rightPad,
|
|
824
|
+
leftPad: Math.max(0, terminalWidth - rightPad - frameWidth),
|
|
825
|
+
frameWidth,
|
|
826
|
+
contentWidth,
|
|
827
|
+
body,
|
|
828
|
+
};
|
|
829
|
+
};
|
|
830
|
+
const border = (text: string) => currentExtensionUi?.theme?.fg
|
|
831
|
+
? currentExtensionUi.theme.fg("accent", text)
|
|
832
|
+
: markdownTheme.quoteBorder(text);
|
|
676
833
|
const frame: Component = {
|
|
677
834
|
render(width: number): string[] {
|
|
678
|
-
const
|
|
679
|
-
const
|
|
680
|
-
const
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
return userMessageBg ? userMessageBg(line + padding) : line + padding;
|
|
684
|
-
};
|
|
685
|
-
if (frameWidth < 4) {
|
|
686
|
-
return content.render(frameWidth).map((line: string) => outer + paintBackground(line));
|
|
835
|
+
const measured = layout(width);
|
|
836
|
+
const left = " ".repeat(measured.leftPad);
|
|
837
|
+
const right = " ".repeat(measured.rightPad);
|
|
838
|
+
if (!measured.framed) {
|
|
839
|
+
return measured.body.map((line: string) => left + line + right);
|
|
687
840
|
}
|
|
688
841
|
|
|
689
|
-
const
|
|
690
|
-
const
|
|
691
|
-
const
|
|
692
|
-
|
|
693
|
-
const bottom = border("╰" + "─".repeat(Math.max(0, frameWidth - 2)) + "╯");
|
|
694
|
-
const contentWidth = Math.max(1, frameWidth - 4);
|
|
695
|
-
const body = withTranscriptMarkdown(() => content.render(contentWidth)).map((line: string) => {
|
|
696
|
-
const padding = " ".repeat(Math.max(0, contentWidth - visibleWidth(line)));
|
|
842
|
+
const top = border("╭" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╮");
|
|
843
|
+
const bottom = border("╰" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╯");
|
|
844
|
+
const body = measured.body.map((line: string) => {
|
|
845
|
+
const padding = " ".repeat(Math.max(0, measured.contentWidth - visibleWidth(line)));
|
|
697
846
|
return border("│ ") + line + padding + border(" │");
|
|
698
847
|
});
|
|
699
|
-
return [top, ...body, bottom].map((line: string) =>
|
|
848
|
+
return [top, ...body, bottom].map((line: string) => left + line + right);
|
|
700
849
|
},
|
|
701
850
|
handleMouse(event: any) {
|
|
702
|
-
if (!
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
return withTranscriptMarkdown(() => content.handleMouse({
|
|
851
|
+
if (!markdown.handleMouse) return undefined;
|
|
852
|
+
const measured = layout(event.width);
|
|
853
|
+
if (!measured.framed) {
|
|
854
|
+
return withTranscriptMarkdown(() => markdown.handleMouse({
|
|
707
855
|
...event,
|
|
708
|
-
x: event.x -
|
|
709
|
-
width:
|
|
856
|
+
x: event.x - measured.leftPad,
|
|
857
|
+
width: measured.contentWidth,
|
|
710
858
|
}));
|
|
711
859
|
}
|
|
712
|
-
const
|
|
713
|
-
if (
|
|
860
|
+
const contentX = measured.leftPad + 2;
|
|
861
|
+
if (
|
|
862
|
+
event.y <= 0 ||
|
|
863
|
+
event.y > measured.body.length ||
|
|
864
|
+
event.x < contentX ||
|
|
865
|
+
event.x >= contentX + measured.contentWidth
|
|
866
|
+
) {
|
|
714
867
|
return undefined;
|
|
715
868
|
}
|
|
716
|
-
return withTranscriptMarkdown(() =>
|
|
869
|
+
return withTranscriptMarkdown(() => markdown.handleMouse({
|
|
717
870
|
...event,
|
|
718
|
-
x: event.x -
|
|
871
|
+
x: event.x - contentX,
|
|
719
872
|
y: event.y - 1,
|
|
720
|
-
width: contentWidth,
|
|
721
|
-
height:
|
|
873
|
+
width: measured.contentWidth,
|
|
874
|
+
height: measured.body.length,
|
|
722
875
|
}));
|
|
723
876
|
},
|
|
724
877
|
invalidate() {
|
|
725
|
-
|
|
878
|
+
markdown.invalidate?.();
|
|
726
879
|
},
|
|
727
880
|
};
|
|
728
881
|
|
|
@@ -917,15 +1070,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
917
1070
|
);
|
|
918
1071
|
|
|
919
1072
|
const refreshGroup = (group: ActivityGroup) => {
|
|
920
|
-
|
|
921
|
-
if (member.kind === "tool" && member.toolCallId) {
|
|
922
|
-
cleanToolComponents.get(member.toolCallId)?.updateDisplay?.();
|
|
923
|
-
} else if (member.kind === "thinking" && member.messageKey) {
|
|
924
|
-
thinkingComponents.get(member.messageKey)?.invalidate?.();
|
|
925
|
-
} else if (member.kind === "update") {
|
|
926
|
-
activityUpdateComponents.get(member.id)?.invalidate?.();
|
|
927
|
-
}
|
|
928
|
-
}
|
|
1073
|
+
refreshActivityGroup(group);
|
|
929
1074
|
currentTui?.requestRender?.();
|
|
930
1075
|
};
|
|
931
1076
|
|
|
@@ -1091,9 +1236,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1091
1236
|
const decorated = contentLines.map((line, index) =>
|
|
1092
1237
|
truncateToWidth((index === 0 ? prefix : continuation) + line, Math.max(1, width), "")
|
|
1093
1238
|
);
|
|
1094
|
-
const output =
|
|
1095
|
-
|
|
1096
|
-
: decorated
|
|
1239
|
+
const output = withActivityCollapse(
|
|
1240
|
+
group, member, width,
|
|
1241
|
+
position.first ? ["", ...renderActivityGroupSummary(group, width), ...decorated] : decorated,
|
|
1242
|
+
);
|
|
1097
1243
|
if (cacheable) {
|
|
1098
1244
|
this[cleanThinkingRenderCacheKey] = {
|
|
1099
1245
|
width,
|
|
@@ -1134,10 +1280,11 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1134
1280
|
// visible Markdown. The Markdown is rendered after the activity rows;
|
|
1135
1281
|
// route clicks in that suffix back to Pi's native component so links,
|
|
1136
1282
|
// text selection, and code-block Copy controls keep working.
|
|
1283
|
+
let activityHeight: number | undefined;
|
|
1137
1284
|
if (visibleAssistantText(message)) {
|
|
1138
1285
|
const visibleLines = originalRender.call(this, event.width);
|
|
1139
1286
|
const renderedLines = patchedRender.call(this, event.width);
|
|
1140
|
-
|
|
1287
|
+
activityHeight = Math.max(0, renderedLines.length - visibleLines.length);
|
|
1141
1288
|
if (event.y >= activityHeight) {
|
|
1142
1289
|
return originalHandleMouse.call(this, {
|
|
1143
1290
|
...event,
|
|
@@ -1149,25 +1296,21 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1149
1296
|
|
|
1150
1297
|
if (!activityGroupRevealed(group)) {
|
|
1151
1298
|
if (!isLeftClick) return undefined;
|
|
1152
|
-
if (position.first)
|
|
1153
|
-
revealedActivityGroups.add(group.id);
|
|
1154
|
-
for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
|
|
1155
|
-
refreshGroup(group);
|
|
1156
|
-
}
|
|
1299
|
+
if (position.first) revealActivityGroup(group);
|
|
1157
1300
|
return { handled: true };
|
|
1158
1301
|
}
|
|
1159
1302
|
const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
refreshGroup(group);
|
|
1303
|
+
const collapseRow = position.last
|
|
1304
|
+
? (activityHeight ?? patchedRender.call(this, event.width).length) - 1
|
|
1305
|
+
: -1;
|
|
1306
|
+
if (
|
|
1307
|
+
(position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) ||
|
|
1308
|
+
activityCollapseClicked(group, member, event, collapseRow)
|
|
1309
|
+
) {
|
|
1310
|
+
collapseActivityGroup(group);
|
|
1169
1311
|
return { handled: true };
|
|
1170
1312
|
}
|
|
1313
|
+
if (position.last && event.y === collapseRow) return undefined;
|
|
1171
1314
|
if (isLeftClick) {
|
|
1172
1315
|
if (expandedThinkingMembers.has(member.id)) expandedThinkingMembers.delete(member.id);
|
|
1173
1316
|
else expandedThinkingMembers.add(member.id);
|
|
@@ -1292,6 +1435,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1292
1435
|
const originalShowError = interactiveModePrototype.showError;
|
|
1293
1436
|
const patchedSetToolsExpanded = function (this: any, expanded: boolean) {
|
|
1294
1437
|
cleanToolsExpanded = expanded;
|
|
1438
|
+
collapsedActivityGroups.clear();
|
|
1295
1439
|
changingAllToolsExpansion = true;
|
|
1296
1440
|
if (!expanded) {
|
|
1297
1441
|
cleanCompactToolCallIds.clear();
|
|
@@ -1789,26 +1933,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
1789
1933
|
event.y === candidate.y && event.x >= candidate.xStart && event.x < candidate.xEnd,
|
|
1790
1934
|
);
|
|
1791
1935
|
if (region) {
|
|
1792
|
-
if (event.type === "click")
|
|
1793
|
-
if (typeof currentTui?.copyTextToClipboard === "function") {
|
|
1794
|
-
// Match fullscreen selection exactly: use Pi TUI's clipboard path
|
|
1795
|
-
// and its transient "Copied!" / "Copy failed" flash feedback.
|
|
1796
|
-
void Promise.resolve(currentTui.copyTextToClipboard(region.code)).catch(() => {
|
|
1797
|
-
currentTui?.flash?.("Copy failed");
|
|
1798
|
-
});
|
|
1799
|
-
} else {
|
|
1800
|
-
// Defensive fallback for older Pi versions without the fullscreen helper.
|
|
1801
|
-
void copyToClipboard(region.code)
|
|
1802
|
-
.then(() => setLatestActivityStatus({
|
|
1803
|
-
title: "Copied!",
|
|
1804
|
-
severity: "success",
|
|
1805
|
-
}))
|
|
1806
|
-
.catch(() => setLatestActivityStatus({
|
|
1807
|
-
title: "Copy failed",
|
|
1808
|
-
severity: "error",
|
|
1809
|
-
}));
|
|
1810
|
-
}
|
|
1811
|
-
}
|
|
1936
|
+
if (event.type === "click") copyTranscriptText(region.code);
|
|
1812
1937
|
return { handled: true, render: false };
|
|
1813
1938
|
}
|
|
1814
1939
|
}
|
|
@@ -2347,32 +2472,8 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2347
2472
|
const originalToolHandleMouse = toolExecutionPrototype.handleMouse;
|
|
2348
2473
|
const renderedModeKey = Symbol("pretty-tui.tool-rendered-mode");
|
|
2349
2474
|
const cleanChildRenderCacheKey = Symbol("pretty-tui.clean-child-render-cache");
|
|
2350
|
-
const
|
|
2351
|
-
|
|
2352
|
-
if (member.kind === "tool" && member.toolCallId) {
|
|
2353
|
-
cleanToolComponents.get(member.toolCallId)?.updateDisplay();
|
|
2354
|
-
} else if (member.kind === "thinking" && member.messageKey) {
|
|
2355
|
-
thinkingComponents.get(member.messageKey)?.invalidate?.();
|
|
2356
|
-
}
|
|
2357
|
-
}
|
|
2358
|
-
};
|
|
2359
|
-
const revealCleanGroup = (group: ActivityGroup, ui?: any) => {
|
|
2360
|
-
revealedActivityGroups.add(group.id);
|
|
2361
|
-
for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
|
|
2362
|
-
refreshActivityGroup(group);
|
|
2363
|
-
ui?.requestRender?.();
|
|
2364
|
-
};
|
|
2365
|
-
const collapseCleanGroup = (group: ActivityGroup, ui?: any) => {
|
|
2366
|
-
revealedActivityGroups.delete(group.id);
|
|
2367
|
-
for (const toolCallId of group.toolCallIds) {
|
|
2368
|
-
const component = cleanToolComponents.get(toolCallId);
|
|
2369
|
-
if (component?.expanded) originalSetExpanded.call(component, false);
|
|
2370
|
-
cleanCompactToolCallIds.delete(toolCallId);
|
|
2371
|
-
}
|
|
2372
|
-
for (const member of group.members) expandedThinkingMembers.delete(member.id);
|
|
2373
|
-
refreshActivityGroup(group);
|
|
2374
|
-
ui?.requestRender?.();
|
|
2375
|
-
};
|
|
2475
|
+
const revealCleanGroup = revealActivityGroup;
|
|
2476
|
+
const collapseCleanGroup = collapseActivityGroup;
|
|
2376
2477
|
const patchedMarkExecutionStarted = function (this: any) {
|
|
2377
2478
|
activityTimeline.addTool(this.toolCallId, this.toolName);
|
|
2378
2479
|
if (!cleanRun.currentToolCallIds.includes(this.toolCallId)) {
|
|
@@ -2433,7 +2534,11 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2433
2534
|
continuation,
|
|
2434
2535
|
childWidth,
|
|
2435
2536
|
} = activityTreeStyle(group, member, width, childTheme);
|
|
2436
|
-
|
|
2537
|
+
// Other extensions (notably FFF in override mode) can register their own
|
|
2538
|
+
// grep/find renderers under built-in names. Their default boxed shell is
|
|
2539
|
+
// not one of our self-rendering specialized tools.
|
|
2540
|
+
const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName) ||
|
|
2541
|
+
this.toolDefinition?.renderShell !== "self";
|
|
2437
2542
|
const cacheable = !this.expanded && Boolean(this.result) && !this.isPartial;
|
|
2438
2543
|
const cached = cacheable ? this[cleanChildRenderCacheKey] : undefined;
|
|
2439
2544
|
let decoratedContent: string[];
|
|
@@ -2499,8 +2604,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2499
2604
|
};
|
|
2500
2605
|
}
|
|
2501
2606
|
}
|
|
2502
|
-
|
|
2503
|
-
|
|
2607
|
+
return withActivityCollapse(
|
|
2608
|
+
group, member, width,
|
|
2609
|
+
position.first ? ["", ...renderActivityGroupSummary(group, width), ...decoratedContent] : decoratedContent,
|
|
2610
|
+
);
|
|
2504
2611
|
};
|
|
2505
2612
|
const patchedToolHandleMouse = function (this: any, event: any) {
|
|
2506
2613
|
const member = activityTimeline.memberForTool(this.toolCallId);
|
|
@@ -2519,7 +2626,11 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2519
2626
|
if (!revealed) return isLeftClick ? { handled: true } : undefined;
|
|
2520
2627
|
|
|
2521
2628
|
const summaryHeight = position.first ? renderActivityGroupSummary(group, event.width).length : 0;
|
|
2522
|
-
|
|
2629
|
+
const onSummary = position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick;
|
|
2630
|
+
const onCollapse = position.last && activityCollapseClicked(
|
|
2631
|
+
group, member, event, patchedToolRender.call(this, event.width).length - 1,
|
|
2632
|
+
);
|
|
2633
|
+
if (onSummary || onCollapse) {
|
|
2523
2634
|
changingAllToolsExpansion = true;
|
|
2524
2635
|
try {
|
|
2525
2636
|
collapseCleanGroup(group, this.ui);
|
|
@@ -2533,7 +2644,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2533
2644
|
// replaces that spacer with the compact header for non-first members.
|
|
2534
2645
|
const thirdPartyHeaderY = position.first ? summaryHeight + 1 : 0;
|
|
2535
2646
|
if (
|
|
2536
|
-
!SPECIALIZED_TOOL_NAMES.has(this.toolName) &&
|
|
2647
|
+
(!SPECIALIZED_TOOL_NAMES.has(this.toolName) || this.toolDefinition?.renderShell !== "self") &&
|
|
2537
2648
|
isLeftClick &&
|
|
2538
2649
|
event.y === thirdPartyHeaderY
|
|
2539
2650
|
) {
|
|
@@ -2879,6 +2990,62 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2879
2990
|
(renderMode === "clean" && isCleanGroupRevealed(toolCallId))
|
|
2880
2991
|
);
|
|
2881
2992
|
|
|
2993
|
+
pi.registerEntryRenderer<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, (entry, _options, theme) => {
|
|
2994
|
+
let copyStart = -1;
|
|
2995
|
+
let copyWidth = 0;
|
|
2996
|
+
return {
|
|
2997
|
+
render(width: number): string[] {
|
|
2998
|
+
const data = entry.data;
|
|
2999
|
+
if (!data || width <= 0) return [];
|
|
3000
|
+
copyStart = -1;
|
|
3001
|
+
copyWidth = 0;
|
|
3002
|
+
const answer = data.answerEntryId ? responseAnswerTexts.get(data.answerEntryId) ?? "" : "";
|
|
3003
|
+
const copyLabel = "[Copy]";
|
|
3004
|
+
const showCopy = fullscreenTui && Boolean(answer.trim()) && width >= 30;
|
|
3005
|
+
const duration = formatResponseDuration(data.durationMs);
|
|
3006
|
+
const statusText = data.outcome === "stopped"
|
|
3007
|
+
? `⚠ Stopped after ${duration}`
|
|
3008
|
+
: `✓ Completed in ${duration}`;
|
|
3009
|
+
const leading = "── ";
|
|
3010
|
+
const trailing = showCopy ? ` ${copyLabel} ──` : "──";
|
|
3011
|
+
const fixedWidth = visibleWidth(leading) + visibleWidth(trailing) + 1;
|
|
3012
|
+
if (width <= fixedWidth) return [theme.fg("dim", "─".repeat(width))];
|
|
3013
|
+
|
|
3014
|
+
const statusWidth = Math.max(1, width - fixedWidth);
|
|
3015
|
+
const visibleStatus = truncateToWidth(statusText, statusWidth, "…");
|
|
3016
|
+
const prefix = leading + visibleStatus + " ";
|
|
3017
|
+
const fill = "─".repeat(Math.max(0, width - visibleWidth(prefix) - visibleWidth(trailing)));
|
|
3018
|
+
const line = prefix + fill + trailing;
|
|
3019
|
+
if (showCopy) {
|
|
3020
|
+
const copyIndex = line.indexOf(copyLabel);
|
|
3021
|
+
copyStart = visibleWidth(line.slice(0, copyIndex));
|
|
3022
|
+
copyWidth = visibleWidth(copyLabel);
|
|
3023
|
+
}
|
|
3024
|
+
// The divider, completion text, and Copy control intentionally share
|
|
3025
|
+
// one quiet gray treatment; position alone communicates the hierarchy.
|
|
3026
|
+
return [theme.fg("dim", line)];
|
|
3027
|
+
},
|
|
3028
|
+
handleMouse(event: TuiMouseEvent) {
|
|
3029
|
+
if (
|
|
3030
|
+
copyStart < 0 ||
|
|
3031
|
+
event.button !== "left" ||
|
|
3032
|
+
(event.type !== "press" && event.type !== "click") ||
|
|
3033
|
+
event.y !== 0 ||
|
|
3034
|
+
event.x < copyStart ||
|
|
3035
|
+
event.x >= copyStart + copyWidth
|
|
3036
|
+
) {
|
|
3037
|
+
return undefined;
|
|
3038
|
+
}
|
|
3039
|
+
if (event.type === "click" && entry.data?.answerEntryId) {
|
|
3040
|
+
const answer = responseAnswerTexts.get(entry.data.answerEntryId);
|
|
3041
|
+
if (answer) copyTranscriptText(answer);
|
|
3042
|
+
}
|
|
3043
|
+
return { handled: true, render: false };
|
|
3044
|
+
},
|
|
3045
|
+
invalidate() {},
|
|
3046
|
+
};
|
|
3047
|
+
});
|
|
3048
|
+
|
|
2882
3049
|
pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
|
|
2883
3050
|
render(width: number): string[] {
|
|
2884
3051
|
if (renderMode !== "clean" || expanded || cleanContextCompacted) return [];
|
|
@@ -2934,8 +3101,12 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2934
3101
|
settledSummaries.clear();
|
|
2935
3102
|
legacySummaryLastToolCallIds.clear();
|
|
2936
3103
|
activityTimeline.clear();
|
|
3104
|
+
responseAnswerTexts.clear();
|
|
3105
|
+
responseRunStartedAt = undefined;
|
|
3106
|
+
responseHasAssistantMessage = false;
|
|
2937
3107
|
nativeCustomBoundaryKeys.clear();
|
|
2938
3108
|
revealedActivityGroups.clear();
|
|
3109
|
+
collapsedActivityGroups.clear();
|
|
2939
3110
|
expandedThinkingMembers.clear();
|
|
2940
3111
|
thinkingComponents.clear();
|
|
2941
3112
|
activityFallbackThemes.clear();
|
|
@@ -3157,6 +3328,8 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3157
3328
|
}
|
|
3158
3329
|
|
|
3159
3330
|
if (message.role === "assistant") {
|
|
3331
|
+
const answerText = assistantAnswerText(message);
|
|
3332
|
+
if (answerText) responseAnswerTexts.set(entry.id, answerText);
|
|
3160
3333
|
const thinking = thinkingText(message);
|
|
3161
3334
|
if (thinking) {
|
|
3162
3335
|
activityTimeline.addThinking(assistantMessageKey(message), thinking);
|
|
@@ -3306,6 +3479,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3306
3479
|
trackPendingToolActivity(event.message);
|
|
3307
3480
|
});
|
|
3308
3481
|
pi.on("message_end", (event) => {
|
|
3482
|
+
if (event.message?.role === "assistant") responseHasAssistantMessage = true;
|
|
3309
3483
|
if (hasVisibleAssistantText(event.message)) {
|
|
3310
3484
|
trackThinkingActivity(event.message);
|
|
3311
3485
|
closeAtAssistantBoundary(event.message, "done");
|
|
@@ -3328,6 +3502,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3328
3502
|
// count until the whole run reaches agent_settled.
|
|
3329
3503
|
if (!cleanRun.active) {
|
|
3330
3504
|
clearToolActivityHolds();
|
|
3505
|
+
responseRunStartedAt = Date.now();
|
|
3331
3506
|
cleanRun.count = 0;
|
|
3332
3507
|
cleanRun.failed = 0;
|
|
3333
3508
|
cleanRun.currentToolCallIds = [];
|
|
@@ -3336,6 +3511,9 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3336
3511
|
cleanRun.lastCompletedToolCallId = undefined;
|
|
3337
3512
|
cleanRun.settled = false;
|
|
3338
3513
|
}
|
|
3514
|
+
// Retries share the original start time but only the newest low-level
|
|
3515
|
+
// run may supply a candidate final answer.
|
|
3516
|
+
responseHasAssistantMessage = false;
|
|
3339
3517
|
cleanRun.active = true;
|
|
3340
3518
|
cleanRun.activeToolCallIds.clear();
|
|
3341
3519
|
cleanRun.activeToolCallId = undefined;
|
|
@@ -3389,7 +3567,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3389
3567
|
cleanRun.activeToolName = undefined;
|
|
3390
3568
|
cleanRun.activity = "thinking";
|
|
3391
3569
|
});
|
|
3392
|
-
pi.on("agent_settled", () => {
|
|
3570
|
+
pi.on("agent_settled", (_event, ctx) => {
|
|
3393
3571
|
if (cleanRun.settled) return;
|
|
3394
3572
|
|
|
3395
3573
|
clearPendingToolActivities();
|
|
@@ -3411,6 +3589,30 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3411
3589
|
cleanRun.activity = "done";
|
|
3412
3590
|
cleanRun.settled = true;
|
|
3413
3591
|
|
|
3592
|
+
if (responseRunStartedAt !== undefined) {
|
|
3593
|
+
// message_end runs before later extensions can replace the message and
|
|
3594
|
+
// before Pi persists it. Read the committed branch at the final boundary
|
|
3595
|
+
// so Copy matches the transcript even when another extension redacts it.
|
|
3596
|
+
const finalEntry = responseHasAssistantMessage
|
|
3597
|
+
? ctx.sessionManager.getBranch().reverse().find(
|
|
3598
|
+
(entry) => entry.type === "message" && entry.message.role === "assistant",
|
|
3599
|
+
)
|
|
3600
|
+
: undefined;
|
|
3601
|
+
const answer = finalEntry && finalEntry.type === "message"
|
|
3602
|
+
? assistantAnswerText(finalEntry.message)
|
|
3603
|
+
: "";
|
|
3604
|
+
if (answer && finalEntry) responseAnswerTexts.set(finalEntry.id, answer);
|
|
3605
|
+
pi.appendEntry<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, {
|
|
3606
|
+
answerEntryId: answer ? finalEntry?.id : undefined,
|
|
3607
|
+
durationMs: Math.max(0, Date.now() - responseRunStartedAt),
|
|
3608
|
+
outcome: finalEntry?.type === "message" && assistantTerminalState(finalEntry.message)
|
|
3609
|
+
? "stopped"
|
|
3610
|
+
: "completed",
|
|
3611
|
+
});
|
|
3612
|
+
responseRunStartedAt = undefined;
|
|
3613
|
+
responseHasAssistantMessage = false;
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3414
3616
|
const groups = cleanRun.groups.slice();
|
|
3415
3617
|
if (renderMode !== "clean" || groups.length === 0) return;
|
|
3416
3618
|
|