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 +4 -3
- package/README.zh-CN.md +4 -3
- package/extensions/index.ts +227 -58
- 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
|
|
|
@@ -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
|
|
|
@@ -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,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
|
|
656
|
-
//
|
|
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
|
-
//
|
|
667
|
-
|
|
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
|
-
|
|
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
|
|
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));
|
|
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
|
|
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)));
|
|
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) =>
|
|
793
|
+
return [top, ...body, bottom].map((line: string) => left + line + right);
|
|
700
794
|
},
|
|
701
795
|
handleMouse(event: any) {
|
|
702
|
-
if (!
|
|
703
|
-
const
|
|
704
|
-
|
|
705
|
-
|
|
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 -
|
|
709
|
-
width:
|
|
801
|
+
x: event.x - measured.leftPad,
|
|
802
|
+
width: measured.contentWidth,
|
|
710
803
|
}));
|
|
711
804
|
}
|
|
712
|
-
const
|
|
713
|
-
if (
|
|
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(() =>
|
|
814
|
+
return withTranscriptMarkdown(() => markdown.handleMouse({
|
|
717
815
|
...event,
|
|
718
|
-
x: event.x -
|
|
816
|
+
x: event.x - contentX,
|
|
719
817
|
y: event.y - 1,
|
|
720
|
-
width: contentWidth,
|
|
721
|
-
height:
|
|
818
|
+
width: measured.contentWidth,
|
|
819
|
+
height: measured.body.length,
|
|
722
820
|
}));
|
|
723
821
|
},
|
|
724
822
|
invalidate() {
|
|
725
|
-
|
|
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
|
|