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 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:** rounded user messages and prompt input, theme-aware colors, a clear Markdown heading hierarchy, cleaner lists, and rounded syntax-highlighted code blocks make the entire TUI feel intentional. In fullscreen, each fenced code block also includes a native-feeling `[Copy]` control.
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, or click the parent again to collapse the whole group. Press `Ctrl+O` at any time to use Pi's global expansion control for all tool output and thinking content. 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
+ 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. Framing native user messages, 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
+ 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
- - **统一、精致的视觉体验:** 圆角用户消息与输入框、主题感知配色、层级清晰的 Markdown 标题、更清爽的列表,以及带语法高亮的圆角代码块,让整个 TUI 更协调。在 fullscreen 模式下,每个围栏代码块还带有符合原生体验的 `[Copy]` 控件。
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` 和扩展更新。点击单个工具或思考子项可在保留摘要的同时展开嵌套详情,再次点击父级可折叠整个分组。任何时候都可以按 `Ctrl+O`,使用 Pi 的全局展开控制来显示或隐藏全部工具输出和思考内容。工具名称会原样使用定义中的 `label`,没有 `label` 时回退到原始 `name`;pi-pretty-tui 不再重命名或转换大小写。
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、重复摘要防护、原型补丁的完整恢复,以及 fullscreen 代码块复制的点击区域。GitHub Actions 会在每次 push 和 pull request 时运行相同检查。
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 目前没有为这些展示细节提供公开渲染钩子,原生用户消息框、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 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
 
@@ -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
- cleanToolsExpanded || revealedActivityGroups.has(group.id);
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
- const rawPrefix = last ? " └─ " : " ├─ ";
322
- const rawContinuation = last ? " " : " │ ";
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 position.first
410
- ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
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
- revealedActivityGroups.add(group.id);
455
- for (const toolCallId of group.toolCallIds) cleanCompactToolCallIds.add(toolCallId);
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
- currentTui?.requestRender?.();
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 a titled, rounded frame while preserving Pi's
656
- // original Markdown component, wrapping, output padding, and OSC 133 zones.
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
- // Reuse Pi's theme-aware user background across the complete frame.
667
- const userMessageBg = content.bgFn as ((text: string) => string) | undefined;
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
- const border = (text: string) => markdownTheme.quoteBorder(text);
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 sidePad = Math.min(outputPad, Math.max(0, Math.floor((width - 1) / 2)));
679
- const outer = " ".repeat(sidePad);
680
- const frameWidth = Math.max(1, width - sidePad * 2);
681
- const paintBackground = (line: string) => {
682
- const padding = " ".repeat(Math.max(0, frameWidth - visibleWidth(line)));
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 title = " User ";
690
- const topStart = "╭─";
691
- const topTail = "─".repeat(Math.max(0, frameWidth - visibleWidth(topStart) - visibleWidth(title) - 1));
692
- const top = border(topStart + markdownTheme.bold(title) + topTail + "╮");
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) => outer + paintBackground(line));
848
+ return [top, ...body, bottom].map((line: string) => left + line + right);
700
849
  },
701
850
  handleMouse(event: any) {
702
- if (!content.handleMouse) return undefined;
703
- const sidePad = Math.min(outputPad, Math.max(0, Math.floor((event.width - 1) / 2)));
704
- const frameWidth = Math.max(1, event.width - sidePad * 2);
705
- if (frameWidth < 4) {
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 - sidePad,
709
- width: frameWidth,
856
+ x: event.x - measured.leftPad,
857
+ width: measured.contentWidth,
710
858
  }));
711
859
  }
712
- const contentWidth = Math.max(1, frameWidth - 4);
713
- if (event.y <= 0 || event.x < sidePad + 2 || event.x >= sidePad + 2 + contentWidth) {
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(() => content.handleMouse({
869
+ return withTranscriptMarkdown(() => markdown.handleMouse({
717
870
  ...event,
718
- x: event.x - sidePad - 2,
871
+ x: event.x - contentX,
719
872
  y: event.y - 1,
720
- width: contentWidth,
721
- height: Math.max(0, event.height - 2),
873
+ width: measured.contentWidth,
874
+ height: measured.body.length,
722
875
  }));
723
876
  },
724
877
  invalidate() {
725
- content.invalidate?.();
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
- for (const member of group.members) {
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 = position.first
1095
- ? ["", ...renderActivityGroupSummary(group, width), ...decorated]
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
- const activityHeight = Math.max(0, renderedLines.length - visibleLines.length);
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
- if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
1161
- revealedActivityGroups.delete(group.id);
1162
- for (const toolCallId of group.toolCallIds) {
1163
- cleanCompactToolCallIds.delete(toolCallId);
1164
- const component = cleanToolComponents.get(toolCallId);
1165
- if (component?.expanded) component.setExpanded(false);
1166
- }
1167
- for (const child of group.members) expandedThinkingMembers.delete(child.id);
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 refreshActivityGroup = (group: ActivityGroup) => {
2351
- for (const member of group.members) {
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
- const thirdParty = !SPECIALIZED_TOOL_NAMES.has(this.toolName);
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
- if (!position.first) return decoratedContent;
2503
- return ["", ...renderActivityGroupSummary(group, width), ...decoratedContent];
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
- if (position.first && event.y > 0 && event.y <= summaryHeight && isLeftClick) {
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-pretty-tui",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
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>",