pi-pretty-tui 0.2.1 → 0.2.2

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
 
@@ -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
 
@@ -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,6 +169,49 @@ 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>();
173
217
  const expandedThinkingMembers = new Set<string>();
@@ -652,8 +696,10 @@ export default function prettyTui(pi: ExtensionAPI) {
652
696
  });
653
697
  }
654
698
 
655
- // Render user prompts as a titled, rounded frame while preserving Pi's
656
- // original Markdown component, wrapping, output padding, and OSC 133 zones.
699
+ // Render user prompts as right-aligned, content-sized chat bubbles while
700
+ // preserving Pi's Markdown component, wrapping, output padding, and OSC 133
701
+ // zones. Long messages stop at 75% of the terminal width; narrow terminals
702
+ // trade away the left gap before sacrificing readability.
657
703
  const userMessagePrototype = UserMessageComponent.prototype as any;
658
704
  const userMessagePatchKey = Symbol.for("pretty-tui.user-message-frame");
659
705
  if (!userMessagePrototype[userMessagePatchKey]) {
@@ -663,66 +709,118 @@ export default function prettyTui(pi: ExtensionAPI) {
663
709
  const content = this.children?.[0] as any;
664
710
  if (!content) return;
665
711
 
666
- // Reuse Pi's theme-aware user background across the complete frame.
667
- const userMessageBg = content.bgFn as ((text: string) => string) | undefined;
712
+ // Remove Pi's full-width user-message background. The Markdown keeps its
713
+ // native foreground styling, while the surrounding bubble stays transparent.
668
714
  content.paddingX = 0;
669
715
  content.paddingY = 0;
670
716
  content.setBgFn?.(undefined);
671
717
  content.invalidate?.();
718
+ // Pi wraps the Markdown in a full-width Box. Render the Markdown child
719
+ // directly so short messages expose their natural visible width instead
720
+ // of inheriting the Box's width-filling padding.
721
+ const markdown = content.children?.[0] ?? content;
672
722
 
673
723
  const outputPad = Math.max(0, Number(this.outputPad) || 0);
674
724
  const markdownTheme = this.markdownTheme;
675
- const border = (text: string) => markdownTheme.quoteBorder(text);
725
+ // Markdown deliberately pads every rendered line to the requested width.
726
+ // Remove only that trailing layout padding before measuring or framing;
727
+ // ANSI styling and the visible content remain untouched.
728
+ const renderMarkdown = (width: number): string[] =>
729
+ withTranscriptMarkdown(() => markdown.render(width))
730
+ .map((line: string) => line.replace(/ +$/u, ""));
731
+ const layout = (width: number) => {
732
+ const terminalWidth = Math.max(1, width);
733
+ const rightPad = Math.min(outputPad, Math.max(0, terminalWidth - 1));
734
+ const availableWidth = Math.max(1, terminalWidth - rightPad);
735
+ if (availableWidth < 4) {
736
+ return {
737
+ framed: false,
738
+ rightPad,
739
+ leftPad: 0,
740
+ frameWidth: availableWidth,
741
+ contentWidth: availableWidth,
742
+ body: renderMarkdown(availableWidth),
743
+ };
744
+ }
745
+
746
+ const preferredMaximum = Math.max(4, Math.floor(terminalWidth * 0.75));
747
+ // Keep at least one blank column on the left whenever a framed bubble
748
+ // fits, even after a narrow terminal relaxes the usual 75% maximum.
749
+ const bubbleAvailable = availableWidth >= 5 ? availableWidth - 1 : availableWidth;
750
+ const narrowReadableMinimum = Math.min(bubbleAvailable, 24);
751
+ const frameMaximum = Math.min(
752
+ bubbleAvailable,
753
+ Math.max(preferredMaximum, narrowReadableMinimum),
754
+ );
755
+ const measuringWidth = Math.max(1, frameMaximum - 4);
756
+ const measuredBody = renderMarkdown(measuringWidth);
757
+ const naturalContentWidth = Math.max(
758
+ 1,
759
+ ...measuredBody.map((line: string) => visibleWidth(line)),
760
+ );
761
+ const frameWidth = Math.min(frameMaximum, naturalContentWidth + 4);
762
+ const contentWidth = Math.max(1, frameWidth - 4);
763
+ const body = contentWidth === measuringWidth
764
+ ? measuredBody
765
+ : renderMarkdown(contentWidth);
766
+ return {
767
+ framed: true,
768
+ rightPad,
769
+ leftPad: Math.max(0, terminalWidth - rightPad - frameWidth),
770
+ frameWidth,
771
+ contentWidth,
772
+ body,
773
+ };
774
+ };
775
+ const border = (text: string) => currentExtensionUi?.theme?.fg
776
+ ? currentExtensionUi.theme.fg("accent", text)
777
+ : markdownTheme.quoteBorder(text);
676
778
  const frame: Component = {
677
779
  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));
780
+ const measured = layout(width);
781
+ const left = " ".repeat(measured.leftPad);
782
+ const right = " ".repeat(measured.rightPad);
783
+ if (!measured.framed) {
784
+ return measured.body.map((line: string) => left + line + right);
687
785
  }
688
786
 
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)));
787
+ const top = border("╭" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╮");
788
+ const bottom = border("╰" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╯");
789
+ const body = measured.body.map((line: string) => {
790
+ const padding = " ".repeat(Math.max(0, measured.contentWidth - visibleWidth(line)));
697
791
  return border("│ ") + line + padding + border(" │");
698
792
  });
699
- return [top, ...body, bottom].map((line: string) => outer + paintBackground(line));
793
+ return [top, ...body, bottom].map((line: string) => left + line + right);
700
794
  },
701
795
  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({
796
+ if (!markdown.handleMouse) return undefined;
797
+ const measured = layout(event.width);
798
+ if (!measured.framed) {
799
+ return withTranscriptMarkdown(() => markdown.handleMouse({
707
800
  ...event,
708
- x: event.x - sidePad,
709
- width: frameWidth,
801
+ x: event.x - measured.leftPad,
802
+ width: measured.contentWidth,
710
803
  }));
711
804
  }
712
- const contentWidth = Math.max(1, frameWidth - 4);
713
- if (event.y <= 0 || event.x < sidePad + 2 || event.x >= sidePad + 2 + contentWidth) {
805
+ const contentX = measured.leftPad + 2;
806
+ if (
807
+ event.y <= 0 ||
808
+ event.y > measured.body.length ||
809
+ event.x < contentX ||
810
+ event.x >= contentX + measured.contentWidth
811
+ ) {
714
812
  return undefined;
715
813
  }
716
- return withTranscriptMarkdown(() => content.handleMouse({
814
+ return withTranscriptMarkdown(() => markdown.handleMouse({
717
815
  ...event,
718
- x: event.x - sidePad - 2,
816
+ x: event.x - contentX,
719
817
  y: event.y - 1,
720
- width: contentWidth,
721
- height: Math.max(0, event.height - 2),
818
+ width: measured.contentWidth,
819
+ height: measured.body.length,
722
820
  }));
723
821
  },
724
822
  invalidate() {
725
- content.invalidate?.();
823
+ markdown.invalidate?.();
726
824
  },
727
825
  };
728
826
 
@@ -1789,26 +1887,7 @@ export default function prettyTui(pi: ExtensionAPI) {
1789
1887
  event.y === candidate.y && event.x >= candidate.xStart && event.x < candidate.xEnd,
1790
1888
  );
1791
1889
  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
- }
1890
+ if (event.type === "click") copyTranscriptText(region.code);
1812
1891
  return { handled: true, render: false };
1813
1892
  }
1814
1893
  }
@@ -2879,6 +2958,62 @@ export default function prettyTui(pi: ExtensionAPI) {
2879
2958
  (renderMode === "clean" && isCleanGroupRevealed(toolCallId))
2880
2959
  );
2881
2960
 
2961
+ pi.registerEntryRenderer<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, (entry, _options, theme) => {
2962
+ let copyStart = -1;
2963
+ let copyWidth = 0;
2964
+ return {
2965
+ render(width: number): string[] {
2966
+ const data = entry.data;
2967
+ if (!data || width <= 0) return [];
2968
+ copyStart = -1;
2969
+ copyWidth = 0;
2970
+ const answer = data.answerEntryId ? responseAnswerTexts.get(data.answerEntryId) ?? "" : "";
2971
+ const copyLabel = "[Copy]";
2972
+ const showCopy = fullscreenTui && Boolean(answer.trim()) && width >= 30;
2973
+ const duration = formatResponseDuration(data.durationMs);
2974
+ const statusText = data.outcome === "stopped"
2975
+ ? `⚠ Stopped after ${duration}`
2976
+ : `✓ Completed in ${duration}`;
2977
+ const leading = "── ";
2978
+ const trailing = showCopy ? ` ${copyLabel} ──` : "──";
2979
+ const fixedWidth = visibleWidth(leading) + visibleWidth(trailing) + 1;
2980
+ if (width <= fixedWidth) return [theme.fg("dim", "─".repeat(width))];
2981
+
2982
+ const statusWidth = Math.max(1, width - fixedWidth);
2983
+ const visibleStatus = truncateToWidth(statusText, statusWidth, "…");
2984
+ const prefix = leading + visibleStatus + " ";
2985
+ const fill = "─".repeat(Math.max(0, width - visibleWidth(prefix) - visibleWidth(trailing)));
2986
+ const line = prefix + fill + trailing;
2987
+ if (showCopy) {
2988
+ const copyIndex = line.indexOf(copyLabel);
2989
+ copyStart = visibleWidth(line.slice(0, copyIndex));
2990
+ copyWidth = visibleWidth(copyLabel);
2991
+ }
2992
+ // The divider, completion text, and Copy control intentionally share
2993
+ // one quiet gray treatment; position alone communicates the hierarchy.
2994
+ return [theme.fg("dim", line)];
2995
+ },
2996
+ handleMouse(event: TuiMouseEvent) {
2997
+ if (
2998
+ copyStart < 0 ||
2999
+ event.button !== "left" ||
3000
+ (event.type !== "press" && event.type !== "click") ||
3001
+ event.y !== 0 ||
3002
+ event.x < copyStart ||
3003
+ event.x >= copyStart + copyWidth
3004
+ ) {
3005
+ return undefined;
3006
+ }
3007
+ if (event.type === "click" && entry.data?.answerEntryId) {
3008
+ const answer = responseAnswerTexts.get(entry.data.answerEntryId);
3009
+ if (answer) copyTranscriptText(answer);
3010
+ }
3011
+ return { handled: true, render: false };
3012
+ },
3013
+ invalidate() {},
3014
+ };
3015
+ });
3016
+
2882
3017
  pi.registerEntryRenderer<ToolSummaryData>("pretty-tui-tool-summary", (entry, { expanded }, theme) => ({
2883
3018
  render(width: number): string[] {
2884
3019
  if (renderMode !== "clean" || expanded || cleanContextCompacted) return [];
@@ -2934,6 +3069,9 @@ export default function prettyTui(pi: ExtensionAPI) {
2934
3069
  settledSummaries.clear();
2935
3070
  legacySummaryLastToolCallIds.clear();
2936
3071
  activityTimeline.clear();
3072
+ responseAnswerTexts.clear();
3073
+ responseRunStartedAt = undefined;
3074
+ responseHasAssistantMessage = false;
2937
3075
  nativeCustomBoundaryKeys.clear();
2938
3076
  revealedActivityGroups.clear();
2939
3077
  expandedThinkingMembers.clear();
@@ -3157,6 +3295,8 @@ export default function prettyTui(pi: ExtensionAPI) {
3157
3295
  }
3158
3296
 
3159
3297
  if (message.role === "assistant") {
3298
+ const answerText = assistantAnswerText(message);
3299
+ if (answerText) responseAnswerTexts.set(entry.id, answerText);
3160
3300
  const thinking = thinkingText(message);
3161
3301
  if (thinking) {
3162
3302
  activityTimeline.addThinking(assistantMessageKey(message), thinking);
@@ -3306,6 +3446,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3306
3446
  trackPendingToolActivity(event.message);
3307
3447
  });
3308
3448
  pi.on("message_end", (event) => {
3449
+ if (event.message?.role === "assistant") responseHasAssistantMessage = true;
3309
3450
  if (hasVisibleAssistantText(event.message)) {
3310
3451
  trackThinkingActivity(event.message);
3311
3452
  closeAtAssistantBoundary(event.message, "done");
@@ -3328,6 +3469,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3328
3469
  // count until the whole run reaches agent_settled.
3329
3470
  if (!cleanRun.active) {
3330
3471
  clearToolActivityHolds();
3472
+ responseRunStartedAt = Date.now();
3331
3473
  cleanRun.count = 0;
3332
3474
  cleanRun.failed = 0;
3333
3475
  cleanRun.currentToolCallIds = [];
@@ -3336,6 +3478,9 @@ export default function prettyTui(pi: ExtensionAPI) {
3336
3478
  cleanRun.lastCompletedToolCallId = undefined;
3337
3479
  cleanRun.settled = false;
3338
3480
  }
3481
+ // Retries share the original start time but only the newest low-level
3482
+ // run may supply a candidate final answer.
3483
+ responseHasAssistantMessage = false;
3339
3484
  cleanRun.active = true;
3340
3485
  cleanRun.activeToolCallIds.clear();
3341
3486
  cleanRun.activeToolCallId = undefined;
@@ -3389,7 +3534,7 @@ export default function prettyTui(pi: ExtensionAPI) {
3389
3534
  cleanRun.activeToolName = undefined;
3390
3535
  cleanRun.activity = "thinking";
3391
3536
  });
3392
- pi.on("agent_settled", () => {
3537
+ pi.on("agent_settled", (_event, ctx) => {
3393
3538
  if (cleanRun.settled) return;
3394
3539
 
3395
3540
  clearPendingToolActivities();
@@ -3411,6 +3556,30 @@ export default function prettyTui(pi: ExtensionAPI) {
3411
3556
  cleanRun.activity = "done";
3412
3557
  cleanRun.settled = true;
3413
3558
 
3559
+ if (responseRunStartedAt !== undefined) {
3560
+ // message_end runs before later extensions can replace the message and
3561
+ // before Pi persists it. Read the committed branch at the final boundary
3562
+ // so Copy matches the transcript even when another extension redacts it.
3563
+ const finalEntry = responseHasAssistantMessage
3564
+ ? ctx.sessionManager.getBranch().reverse().find(
3565
+ (entry) => entry.type === "message" && entry.message.role === "assistant",
3566
+ )
3567
+ : undefined;
3568
+ const answer = finalEntry && finalEntry.type === "message"
3569
+ ? assistantAnswerText(finalEntry.message)
3570
+ : "";
3571
+ if (answer && finalEntry) responseAnswerTexts.set(finalEntry.id, answer);
3572
+ pi.appendEntry<ResponseFooterData>(RESPONSE_FOOTER_ENTRY_TYPE, {
3573
+ answerEntryId: answer ? finalEntry?.id : undefined,
3574
+ durationMs: Math.max(0, Date.now() - responseRunStartedAt),
3575
+ outcome: finalEntry?.type === "message" && assistantTerminalState(finalEntry.message)
3576
+ ? "stopped"
3577
+ : "completed",
3578
+ });
3579
+ responseRunStartedAt = undefined;
3580
+ responseHasAssistantMessage = false;
3581
+ }
3582
+
3414
3583
  const groups = cleanRun.groups.slice();
3415
3584
  if (renderMode !== "clean" || groups.length === 0) return;
3416
3585
 
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.2",
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>",