pi-pretty-tui 0.3.0 → 0.3.1
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 +20 -3
- package/README.zh-CN.md +20 -3
- package/extensions/full-frame.ts +16 -5
- package/extensions/index.ts +93 -44
- package/extensions/selection-ranges.ts +245 -0
- package/extensions/tool-presentation.ts +8 -2
- package/package.json +10 -4
package/README.md
CHANGED
|
@@ -23,7 +23,7 @@ pi install npm:pi-pretty-tui
|
|
|
23
23
|
|
|
24
24
|
Restart Pi or run `/reload` after installation.
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
pi-pretty-tui registers renderers without replacing tool implementations. Extensions that provide tools with built-in names, such as `bash`, keep their own execution and renderers.
|
|
27
27
|
|
|
28
28
|
## Working with tool calls
|
|
29
29
|
|
|
@@ -83,6 +83,21 @@ In fullscreen, interactive activity/tool rows and copy controls tolerate same-ro
|
|
|
83
83
|
|
|
84
84
|
In compact and full, compaction summaries use a theme-colored `compaction ▸/▾` card. The original token count, shortcut hint and complete summary remain available; click the title/body or use native `Ctrl+O` to toggle expansion. Compaction cost notices remain separate. Clean retains Pi’s original compaction box.
|
|
85
85
|
|
|
86
|
+
## Optional border-free selection copying
|
|
87
|
+
|
|
88
|
+
The experimental **Selection copy** option excludes plugin borders, titles and layout padding from mouse-selected text in fullscreen mode. Literal box-drawing characters in content are preserved. It is **off by default** and applies to clean, compact and full.
|
|
89
|
+
|
|
90
|
+
Open `/pretty-tui` and select `Selection copy` to toggle it, or run:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
/pretty-tui selection-copy on
|
|
94
|
+
/pretty-tui selection-copy off
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
The choice is stored as `selectionCopy` in `pretty-tui.json`; toggling automatically reloads Pi. `/pretty-tui status` shows the configured value. When off, no selection-copy/compositor/terminal-write hooks or private row annotations are installed. Disabling the whole extension also disables this feature, while preserving its saved preference. Regular-mode terminal-owned selection is unchanged.
|
|
98
|
+
|
|
99
|
+
This is an opt-in feature while terminal acceptance and internal-API compatibility are being evaluated. Long transcripts retain additional annotation memory; see [the exploration and GC measurements](docs/selection-copy-exploration.md) in the source repository.
|
|
100
|
+
|
|
86
101
|
## Copy user messages
|
|
87
102
|
|
|
88
103
|
In fullscreen mode, left-click a user bubble's body or border to copy its complete original text, preserving Markdown, indentation, and line breaks without the bubble frame. Existing links and code-block `[Copy]` controls retain priority; dragging to select text does not trigger whole-message copy. Pi's native clipboard path provides copy feedback. This copies text, not image attachments. Regular mode keeps terminal-owned mouse selection and copying.
|
|
@@ -108,11 +123,13 @@ npm install
|
|
|
108
123
|
npm test
|
|
109
124
|
```
|
|
110
125
|
|
|
111
|
-
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.
|
|
126
|
+
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. It also uses Pi's real extension loader to check coexistence with a same-named replacement tool. GitHub Actions runs the same checks on every push and pull request.
|
|
127
|
+
|
|
128
|
+
To additionally exercise a local checkout of [pi-bash-bg](https://github.com/mapleluvr/pi-bash-bg), set `PI_BASH_BG_EXTENSION` to its `index.ts` before running `npm test`. This optional integration test executes foreground and automatically backgrounded commands and checks their status and logs.
|
|
112
129
|
|
|
113
130
|
## Compatibility notice
|
|
114
131
|
|
|
115
|
-
The current development and regression-test baseline is **Pi 1.
|
|
132
|
+
The current development and regression-test baseline is **Pi 1.4.0**. User chat bubbles support its direct Markdown layout, using themed, full-width User panels in full/compact and transparent, content-sized bubbles in clean. Nested execution requires Pi 0.99.0 or later; this is a feature requirement, not a promise that every older Pi release is tested.
|
|
116
133
|
|
|
117
134
|
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.
|
|
118
135
|
|
package/README.zh-CN.md
CHANGED
|
@@ -23,7 +23,7 @@ pi install npm:pi-pretty-tui
|
|
|
23
23
|
|
|
24
24
|
安装后重启 Pi,或运行 `/reload`。
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
pi-pretty-tui 只注册渲染器,不替换工具实现。提供 `bash` 等内置同名工具的扩展仍使用自己的执行逻辑和 renderer。
|
|
27
27
|
|
|
28
28
|
## 使用工具调用
|
|
29
29
|
|
|
@@ -83,6 +83,21 @@ fullscreen 模式下,活动树/工具交互行与复制入口允许同一行
|
|
|
83
83
|
|
|
84
84
|
compact 和 full 中,压缩摘要使用主题配色的 `compaction ▸/▾` 卡片,保留原始 token 数、快捷键提示和完整摘要;点击标题/正文或使用原生 `Ctrl+O` 切换展开。压缩费用提示仍独立显示,clean 保留 Pi 原生压缩消息样式。
|
|
85
85
|
|
|
86
|
+
## 可选的无边框选区复制
|
|
87
|
+
|
|
88
|
+
实验性的 **Selection copy** 选项可在 fullscreen 模式下,从鼠标选区的复制结果中排除插件绘制的边框、标题和布局留白,保留正文里真实存在的框线字符。**默认关闭**,适用于 clean、compact 和 full。
|
|
89
|
+
|
|
90
|
+
打开 `/pretty-tui`,选择 `Selection copy` 切换,或直接执行:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
/pretty-tui selection-copy on
|
|
94
|
+
/pretty-tui selection-copy off
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
选择会保存为 `pretty-tui.json` 中的 `selectionCopy`,切换后自动重载 Pi;`/pretty-tui status` 可查看配置值。关闭时不安装选区复制、叠加层或终端写入补丁,也不生成私有行标记。禁用整个插件时,本功能也不会启用,但会保留开关偏好。regular 模式由终端管理的选区保持原样。
|
|
98
|
+
|
|
99
|
+
目前仍需实际终端验收,并依赖 Pi 内部接口,因此作为可选功能保留。长会话会额外保留标记相关内存;源码仓库中的[探索与垃圾回收报告](docs/selection-copy-exploration.md)记录了测试范围和测量结果。
|
|
100
|
+
|
|
86
101
|
## 复制用户消息
|
|
87
102
|
|
|
88
103
|
fullscreen 模式下,左键点击用户气泡的正文或边框,可复制完整原始文本,保留 Markdown、缩进和换行,不包含气泡边框。已有链接和代码块 `[Copy]` 控件保留优先级;拖动选择文本不会触发整条复制。复制使用 Pi 原生剪贴板路径及反馈,只复制文本,不复制图片附件。regular 模式保留终端自身的鼠标选择与复制行为。
|
|
@@ -108,11 +123,13 @@ npm install
|
|
|
108
123
|
npm test
|
|
109
124
|
```
|
|
110
125
|
|
|
111
|
-
测试覆盖实时与历史恢复的活动时间线、自动重试与终止状态、用户/steer/compaction/原生 renderer 边界、十秒 UI 状态替换、reload 与 shutdown 时的旧 timer 与状态清理、独立严重级别样式、内置与第三方工具、可展开思考、并行工具完成归属、孤立 tool call、重复摘要防护、可持久化的工作时长与仅回答复制控件、自适应用户聊天气泡、原型补丁的完整恢复,以及 fullscreen
|
|
126
|
+
测试覆盖实时与历史恢复的活动时间线、自动重试与终止状态、用户/steer/compaction/原生 renderer 边界、十秒 UI 状态替换、reload 与 shutdown 时的旧 timer 与状态清理、独立严重级别样式、内置与第三方工具、可展开思考、并行工具完成归属、孤立 tool call、重复摘要防护、可持久化的工作时长与仅回答复制控件、自适应用户聊天气泡、原型补丁的完整恢复,以及 fullscreen 代码块复制的点击区域。测试还使用 Pi 的真实扩展加载器检查同名替换工具的共存。GitHub Actions 会在每次 push 和 pull request 时运行相同检查。
|
|
127
|
+
|
|
128
|
+
如需额外测试本地 [pi-bash-bg](https://github.com/mapleluvr/pi-bash-bg) 仓库,运行 `npm test` 前将 `PI_BASH_BG_EXTENSION` 指向其 `index.ts`。该可选集成测试会执行前台和自动转后台的命令,并检查状态和日志。
|
|
112
129
|
|
|
113
130
|
## 兼容性说明
|
|
114
131
|
|
|
115
|
-
当前开发与回归测试基线为 **Pi 1.
|
|
132
|
+
当前开发与回归测试基线为 **Pi 1.4.0**。用户气泡已适配该版本直接使用 Markdown 的布局,full/compact 使用带底色、铺满宽度的 User 面板,clean 使用透明、按内容自适应的气泡。嵌套执行需要 Pi 0.99.0 及以后;这是功能要求,不代表每个旧版都经过测试。
|
|
116
133
|
|
|
117
134
|
工具渲染使用 Pi 的公开扩展 API。活动工具状态通过 Pi 导出的交互组件跟随其内置执行状态变化;clean 模式的 thought 详情使用活动时间线自身的展开状态,full 和 compact 模式则保留 Pi 内置的 Thinking 可见性控制。由于 Pi 目前没有为这些展示细节提供公开渲染钩子,自适应的原生用户消息气泡、原生输入编辑器圆角、无序列表标记和围栏代码块增强需要使用可安全重载的运行时补丁。Markdown 增强仅在渲染主 transcript 中的用户消息和助手回复时启用;扩展 overlay 创建的 Markdown 保持 Pi 原生布局。Markdown 解析、语法高亮和终端语义区域仍由 Pi 处理。
|
|
118
135
|
|
package/extensions/full-frame.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { selectableRow, selectionMetadataActive } from "./selection-ranges.js";
|
|
1
2
|
import { cardSurface } from "./card-surface.js";
|
|
2
3
|
import { isNativeImageRow, stripAnsiBackgrounds } from "./ansi-style.js";
|
|
3
4
|
import { stripTerminalSequences, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
@@ -44,7 +45,7 @@ function renderPanel(
|
|
|
44
45
|
): NativeFrame {
|
|
45
46
|
const palette = ["borderMuted", "text", "accent", "success", "error", titleColor, borderColor]
|
|
46
47
|
.map((role) => theme.fg(role, "x")).join("|") + (theme.bold?.("x") ?? "x") +
|
|
47
|
-
cardSurface(theme, borderColor, background).signature;
|
|
48
|
+
cardSurface(theme, borderColor, background).signature + selectionMetadataActive();
|
|
48
49
|
const sectionKey = titleColor + borderColor + footer + background + JSON.stringify([...sections]);
|
|
49
50
|
const cached = cacheOwner ? frameCache.get(cacheOwner) : undefined;
|
|
50
51
|
if (cached && cached.title === title && cached.width === width && cached.palette === palette && cached.sections === sectionKey &&
|
|
@@ -58,6 +59,7 @@ function renderPanel(
|
|
|
58
59
|
const border = (s: string) => theme.fg(borderColor, s);
|
|
59
60
|
const plainCaption = truncateToWidth(title, width - 6, "…");
|
|
60
61
|
const caption = theme.fg(titleColor, theme.bold?.(plainCaption) ?? plainCaption);
|
|
62
|
+
const copyRanges: Array<Array<[number, number]>> | undefined = selectionMetadataActive() ? [[]] : undefined;
|
|
61
63
|
const lines = [border("╭─ ") + caption + border(" " + "─".repeat(Math.max(0, width - visibleWidth(plainCaption) - 5)) + "╮")];
|
|
62
64
|
source.forEach((row, sourceY) => {
|
|
63
65
|
const section = sections.get(sourceY);
|
|
@@ -66,19 +68,28 @@ function renderPanel(
|
|
|
66
68
|
const label = theme.fg(section.color, plainLabel);
|
|
67
69
|
sourceRows.set(lines.length, sourceY);
|
|
68
70
|
lines.push(border("├─ ") + label + border(" " + "─".repeat(Math.max(0, width - visibleWidth(plainLabel) - 5)) + "┤"));
|
|
71
|
+
copyRanges?.push([]);
|
|
69
72
|
}
|
|
70
73
|
sourceRows.set(lines.length, sourceY);
|
|
71
|
-
if (isNativeImageRow(row)) lines.push(row);
|
|
74
|
+
if (isNativeImageRow(row)) { lines.push(row); copyRanges?.push([]); }
|
|
72
75
|
else {
|
|
73
76
|
const content = stripAnsiBackgrounds(row);
|
|
74
|
-
|
|
77
|
+
const length = visibleWidth(content);
|
|
78
|
+
lines.push(border("│ ") + content + " ".repeat(Math.max(0, contentWidth - length)) + border(" │"));
|
|
79
|
+
copyRanges?.push([[2, 2 + Math.max(1, Math.min(contentWidth, length))]]);
|
|
75
80
|
}
|
|
76
81
|
});
|
|
77
82
|
const footerFits = footer && visibleWidth(footer) + 6 <= width;
|
|
78
83
|
lines.push(footerFits
|
|
79
84
|
? border("╰" + "─".repeat(width - visibleWidth(footer) - 5) + " " + footer + " ─╯")
|
|
80
85
|
: border("╰" + "─".repeat(width - 2) + "╯"));
|
|
81
|
-
|
|
86
|
+
copyRanges?.push([]);
|
|
87
|
+
// Apply backgrounds before adding this frame's annotations so the width
|
|
88
|
+
// cache doesn't retain an extra marked variant of every newly painted row.
|
|
89
|
+
const frame = { lines: lines.map((line, i) => {
|
|
90
|
+
const painted = cardBackground(line, width, theme, background, borderColor);
|
|
91
|
+
return copyRanges ? selectableRow(painted, width, copyRanges[i]) : painted;
|
|
92
|
+
}), sourceRows, contentWidth, inset: 2 };
|
|
82
93
|
if (cacheOwner) frameCache.set(cacheOwner, { title, source: source.slice(), width, palette, sections: sectionKey, frame });
|
|
83
94
|
return frame;
|
|
84
95
|
}
|
|
@@ -98,7 +109,7 @@ export function frameNativeRows(
|
|
|
98
109
|
const cached = gutterFrames.get(frame);
|
|
99
110
|
if (cached) return cached;
|
|
100
111
|
const padded = { ...frame, inset: frame.inset + margin,
|
|
101
|
-
lines: frame.lines.map((line) => isNativeImageRow(line) ? line : " ".repeat(margin) + line + " ".repeat(margin)),
|
|
112
|
+
lines: frame.lines.map((line) => isNativeImageRow(line) ? line : selectableRow(" ".repeat(margin) + line + " ".repeat(margin), width, [[margin, width - margin]])),
|
|
102
113
|
};
|
|
103
114
|
gutterFrames.set(frame, padded);
|
|
104
115
|
return padded;
|
package/extensions/index.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
+
import { installSelectionCopy, selectableRow, selectionMetadataActive } from "./selection-ranges.js";
|
|
1
2
|
import { isNativeImageRow } from "./ansi-style.js";
|
|
2
3
|
import { cardMargin, cardBackground, frameNativeRows, nativeFrameWidth } from "./full-frame.js";
|
|
3
4
|
import { ThoughtPresentation } from "./thought-presentation.js";
|
|
4
|
-
import { isThirdPartyTool, renderToolProjection, toolLabel, type ToolProjection } from "./tool-presentation.js";
|
|
5
|
+
import { isThirdPartyTool, renderToolProjection, THIRD_PARTY_TOOL, toolLabel, type ToolProjection } from "./tool-presentation.js";
|
|
5
6
|
import { NestedTools, NESTED_SNAPSHOT_ENTRY } from "./nested-tools.js";
|
|
6
7
|
import {
|
|
7
8
|
AssistantMessageComponent,
|
|
@@ -62,6 +63,8 @@ const RESPONSE_FOOTER_ENTRY_TYPE = "pretty-tui-response-footer";
|
|
|
62
63
|
type PrettyTuiConfig = {
|
|
63
64
|
enabled?: boolean;
|
|
64
65
|
mode?: PrettyTuiMode;
|
|
66
|
+
/** Exclude plugin decoration from fullscreen selections. Opt-in. */
|
|
67
|
+
selectionCopy?: boolean;
|
|
65
68
|
/** Legacy location used by the first mode implementation. */
|
|
66
69
|
bash?: {
|
|
67
70
|
mode?: "full" | "compact";
|
|
@@ -676,7 +679,9 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
676
679
|
{ value: "full", label: "full", description: "Full tool details and output" },
|
|
677
680
|
{ value: "compact", label: "compact", description: "Concise summaries for all built-in tools" },
|
|
678
681
|
{ value: "clean", label: "clean", description: "Group supported tools into Running/Done status" },
|
|
679
|
-
{ value: "
|
|
682
|
+
{ value: "selection-copy on", label: "selection-copy on", description: "Copy selected text without UI borders in fullscreen mode; enable and reload" },
|
|
683
|
+
{ value: "selection-copy off", label: "selection-copy off", description: "Restore native selection copying and reload" },
|
|
684
|
+
{ value: "status", label: "status", description: "Show the current settings" },
|
|
680
685
|
];
|
|
681
686
|
const filtered = items.filter((item) => item.value.startsWith(prefix.trim().toLowerCase()));
|
|
682
687
|
return filtered.length > 0 ? filtered : null;
|
|
@@ -687,7 +692,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
687
692
|
if (!requested) {
|
|
688
693
|
if (!ctx.hasUI) {
|
|
689
694
|
ctx.ui.notify(
|
|
690
|
-
`pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
|
|
695
|
+
`pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode} · selection copy: ${config.selectionCopy === true ? "on" : "off"}`,
|
|
691
696
|
"info",
|
|
692
697
|
);
|
|
693
698
|
return;
|
|
@@ -696,9 +701,12 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
696
701
|
const full = `Full — full tool details and output${renderMode === "full" ? " (current)" : ""}`;
|
|
697
702
|
const compact = `Compact — concise tool summaries and nested calls${renderMode === "compact" ? " (current)" : ""}`;
|
|
698
703
|
const clean = `Clean — group supported tools into Running/Done status${renderMode === "clean" ? " (current)" : ""}`;
|
|
699
|
-
const
|
|
704
|
+
const selectionCopy = `Selection copy — ${config.selectionCopy === true ? "on" : "off"} · copy text without UI borders (fullscreen, experimental)`;
|
|
705
|
+
const selected = await ctx.ui.select("pi-pretty-tui settings", [enabled, full, compact, clean, selectionCopy]);
|
|
700
706
|
if (!selected) return;
|
|
701
|
-
requested = selected ===
|
|
707
|
+
requested = selected === selectionCopy
|
|
708
|
+
? config.selectionCopy === true ? "selection-copy off" : "selection-copy on"
|
|
709
|
+
: selected === enabled
|
|
702
710
|
? config.enabled !== false ? "disable" : "enable"
|
|
703
711
|
: selected === full
|
|
704
712
|
? "full"
|
|
@@ -709,11 +717,28 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
709
717
|
|
|
710
718
|
if (requested === "status") {
|
|
711
719
|
ctx.ui.notify(
|
|
712
|
-
`pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode}`,
|
|
720
|
+
`pi-pretty-tui: ${config.enabled !== false ? "enabled" : "disabled"} · mode: ${renderMode} · selection copy: ${config.selectionCopy === true ? "on" : "off"}`,
|
|
713
721
|
"info",
|
|
714
722
|
);
|
|
715
723
|
return;
|
|
716
724
|
}
|
|
725
|
+
if (requested === "selection-copy on" || requested === "selection-copy off") {
|
|
726
|
+
const selectionCopy = requested.endsWith(" on");
|
|
727
|
+
try {
|
|
728
|
+
saveConfig({ ...config, selectionCopy });
|
|
729
|
+
} catch (error) {
|
|
730
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
731
|
+
ctx.ui.notify(`Could not save selection-copy setting: ${message}`, "error");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
ctx.ui.notify(`Border-free selection copying ${selectionCopy ? "enabled" : "disabled"}; reloading…`, "info");
|
|
735
|
+
try { await ctx.reload(); }
|
|
736
|
+
catch (error) {
|
|
737
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
738
|
+
ctx.ui.notify(`Setting saved, but automatic reload failed: ${message}`, "warning");
|
|
739
|
+
}
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
717
742
|
if (requested === "enable" || requested === "disable") {
|
|
718
743
|
const enabled = requested === "enable";
|
|
719
744
|
try {
|
|
@@ -737,7 +762,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
737
762
|
}
|
|
738
763
|
}
|
|
739
764
|
if (requested !== "full" && requested !== "compact" && requested !== "clean") {
|
|
740
|
-
ctx.ui.notify("Usage: /pretty-tui [enable|disable|full|compact|clean|status]", "error");
|
|
765
|
+
ctx.ui.notify("Usage: /pretty-tui [enable|disable|full|compact|clean|status|selection-copy on|selection-copy off]", "error");
|
|
741
766
|
return;
|
|
742
767
|
}
|
|
743
768
|
|
|
@@ -764,6 +789,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
764
789
|
// render patches or override built-in tools until the next enabled reload.
|
|
765
790
|
if (!activeForSession) return;
|
|
766
791
|
pi.on("session_shutdown", stopWorkingElapsedTimer);
|
|
792
|
+
if (config.selectionCopy === true) pi.on("session_shutdown", installSelectionCopy());
|
|
767
793
|
|
|
768
794
|
// Give Pi's main prompt editor a complete rounded frame. Render the native
|
|
769
795
|
// editor at a two-column narrower width so cursor layout, wrapping, IME, and
|
|
@@ -812,9 +838,9 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
812
838
|
|
|
813
839
|
return lines.map((line, index) => {
|
|
814
840
|
const fitted = fitInnerWidth(line);
|
|
815
|
-
if (index === 0) return this.borderColor("╭") + fitted + this.borderColor("╮");
|
|
816
|
-
if (index === bottomBorderIndex) return this.borderColor("╰") + fitted + this.borderColor("╯");
|
|
817
|
-
if (index < bottomBorderIndex) return this.borderColor("│") + fitted + this.borderColor("│");
|
|
841
|
+
if (index === 0) return selectableRow(this.borderColor("╭") + fitted + this.borderColor("╮"), width, []);
|
|
842
|
+
if (index === bottomBorderIndex) return selectableRow(this.borderColor("╰") + fitted + this.borderColor("╯"), width, []);
|
|
843
|
+
if (index < bottomBorderIndex) return selectableRow(this.borderColor("│") + fitted + this.borderColor("│"), width, [[1, width - 1]]);
|
|
818
844
|
// Autocomplete remains outside the input frame, aligned to its content.
|
|
819
845
|
return " " + fitted + " ";
|
|
820
846
|
});
|
|
@@ -980,9 +1006,10 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
980
1006
|
const bottom = border("╰" + "─".repeat(Math.max(0, measured.frameWidth - 2)) + "╯");
|
|
981
1007
|
const body = measured.body.map((line: string) => {
|
|
982
1008
|
const padding = " ".repeat(Math.max(0, measured.contentWidth - visibleWidth(line)));
|
|
983
|
-
return border("│ ") + line + padding + border(" │");
|
|
1009
|
+
return selectableRow(border("│ ") + line + padding + border(" │"), measured.frameWidth, [[2, 2 + Math.max(1, Math.min(measured.contentWidth, visibleWidth(line)))]]);
|
|
984
1010
|
});
|
|
985
|
-
return [top,
|
|
1011
|
+
return [selectableRow(top, measured.frameWidth, []), ...body, selectableRow(bottom, measured.frameWidth, [])]
|
|
1012
|
+
.map((line: string) => selectableRow(left + line + right, width, [[measured.leftPad, width - measured.rightPad]]));
|
|
986
1013
|
},
|
|
987
1014
|
handleMouse(event: any) {
|
|
988
1015
|
const measured = layout(event.width);
|
|
@@ -2274,6 +2301,21 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2274
2301
|
return originalInvalidate.call(this);
|
|
2275
2302
|
};
|
|
2276
2303
|
|
|
2304
|
+
const copyRows = new WeakMap<object, { source: string[]; padding: number; lines: string[] }>();
|
|
2305
|
+
const selectableMarkdown = (owner: any, source: string[]) => {
|
|
2306
|
+
if (!selectionMetadataActive()) return source;
|
|
2307
|
+
const padding = Math.max(0, Number(owner.paddingX) || 0);
|
|
2308
|
+
if (!padding) return source;
|
|
2309
|
+
const cached = copyRows.get(owner);
|
|
2310
|
+
if (cached?.source === source && cached.padding === padding) return cached.lines;
|
|
2311
|
+
const lines = source.map(line => {
|
|
2312
|
+
const width = visibleWidth(line);
|
|
2313
|
+
return selectableRow(line, width, [[Math.min(padding, width), Math.max(Math.min(padding, width), width - padding)]]);
|
|
2314
|
+
});
|
|
2315
|
+
copyRows.set(owner, { source, padding, lines });
|
|
2316
|
+
return lines;
|
|
2317
|
+
};
|
|
2318
|
+
|
|
2277
2319
|
const patchedRender = function (this: any, width: number): string[] {
|
|
2278
2320
|
const scoped = transcriptMarkdownActive();
|
|
2279
2321
|
mouseClickHitTests.set(this, (event) => fullscreenTui &&
|
|
@@ -2293,7 +2335,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2293
2335
|
if (needsRender) this[codeBlockCollectionKey] = [];
|
|
2294
2336
|
|
|
2295
2337
|
const lines = originalRender.call(this, width);
|
|
2296
|
-
if (!needsRender) return lines;
|
|
2338
|
+
if (!needsRender) return selectableMarkdown(this, lines);
|
|
2297
2339
|
|
|
2298
2340
|
const regions: Array<{ xStart: number; xEnd: number; y: number; code: string }> = [];
|
|
2299
2341
|
let searchFrom = 0;
|
|
@@ -2321,7 +2363,7 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2321
2363
|
}
|
|
2322
2364
|
this[codeBlockRegionsKey] = regions;
|
|
2323
2365
|
delete this[codeBlockCollectionKey];
|
|
2324
|
-
return lines;
|
|
2366
|
+
return selectableMarkdown(this, lines);
|
|
2325
2367
|
};
|
|
2326
2368
|
|
|
2327
2369
|
const patchedRenderToken = function (
|
|
@@ -2479,14 +2521,12 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
2479
2521
|
(showCopyButton ? this.theme.codeBlockBorder(` ${copyLabel} `) : "") + frameBorder("╮");
|
|
2480
2522
|
const framedCodeWidth = Math.max(1, frameWidth - 4);
|
|
2481
2523
|
const lines = [
|
|
2482
|
-
styledTopRule,
|
|
2483
|
-
...codeLines.map((line) =>
|
|
2484
|
-
frameBorder("│ ") +
|
|
2485
|
-
line +
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
),
|
|
2489
|
-
frameBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`),
|
|
2524
|
+
selectableRow(styledTopRule, frameWidth, []),
|
|
2525
|
+
...codeLines.map((line) => selectableRow(
|
|
2526
|
+
frameBorder("│ ") + line +
|
|
2527
|
+
" ".repeat(Math.max(0, framedCodeWidth - visibleWidth(line))) + frameBorder(" │"),
|
|
2528
|
+
frameWidth, [[2, frameWidth - 2]])),
|
|
2529
|
+
selectableRow(frameBorder(`╰${"─".repeat(Math.max(0, frameWidth - 2))}╯`), frameWidth, []),
|
|
2490
2530
|
];
|
|
2491
2531
|
|
|
2492
2532
|
if (showCopyButton && Array.isArray(this[codeBlockCollectionKey])) {
|
|
@@ -3182,13 +3222,12 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
3182
3222
|
if (lines.length === 0) return lines;
|
|
3183
3223
|
contentLines = lines[0] === "" ? lines.slice(1) : lines;
|
|
3184
3224
|
}
|
|
3185
|
-
decoratedContent = contentLines.map((line: string, index: number) =>
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
);
|
|
3225
|
+
decoratedContent = contentLines.map((line: string, index: number) => {
|
|
3226
|
+
const prefix = index === 0 ? childPrefix : continuation;
|
|
3227
|
+
const rendered = truncateToWidth(prefix + line, Math.max(1, width), "");
|
|
3228
|
+
return selectionMetadataActive()
|
|
3229
|
+
? selectableRow(rendered, visibleWidth(rendered), [[visibleWidth(prefix), visibleWidth(rendered)]]) : rendered;
|
|
3230
|
+
});
|
|
3192
3231
|
if (cacheable) {
|
|
3193
3232
|
this[cleanChildRenderCacheKey] = {
|
|
3194
3233
|
width,
|
|
@@ -4333,21 +4372,31 @@ export default function prettyTui(pi: ExtensionAPI) {
|
|
|
4333
4372
|
grep: createGrepToolDefinition(cwd), find: createFindToolDefinition(cwd), ls: createLsToolDefinition(cwd),
|
|
4334
4373
|
};
|
|
4335
4374
|
const registerStyledTool = (definition: any) => {
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
const
|
|
4342
|
-
|
|
4343
|
-
|
|
4344
|
-
|
|
4345
|
-
|
|
4346
|
-
|
|
4347
|
-
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4375
|
+
pi.registerToolRenderer((name, next) => {
|
|
4376
|
+
const downstream = next();
|
|
4377
|
+
if (name !== definition.name) return downstream;
|
|
4378
|
+
// A same-named extension/SDK tool owns its own rendering and parameters.
|
|
4379
|
+
// In particular, do not apply built-in summaries to an unrelated schema.
|
|
4380
|
+
const source = pi.getAllTools().find((tool) => tool.name === name)?.sourceInfo;
|
|
4381
|
+
if (source && source.path !== `builtin:${name}`) {
|
|
4382
|
+
return downstream ? { ...downstream, [THIRD_PARTY_TOOL]: true } : downstream;
|
|
4383
|
+
}
|
|
4384
|
+
const native = nativeDefinitions[name];
|
|
4385
|
+
const select = (kind: "renderCall" | "renderResult", args: any[]) => {
|
|
4386
|
+
const context = args.at(-1);
|
|
4387
|
+
const useNative = renderMode === "full" || renderMode === "compact" && context.expanded;
|
|
4388
|
+
const namespace = useNative ? "nativeFull" : "pretty";
|
|
4389
|
+
const state = context.state[namespace] ??= { state: {} };
|
|
4390
|
+
const scoped = { ...context, state: state.state, lastComponent: state[kind] };
|
|
4391
|
+
const renderer = useNative ? downstream?.[kind] ?? native[kind] : definition[kind];
|
|
4392
|
+
const component = renderer?.(...args.slice(0, -1), scoped);
|
|
4393
|
+
state[kind] = component;
|
|
4394
|
+
return component;
|
|
4395
|
+
};
|
|
4396
|
+
return { ...downstream, renderShell: "self",
|
|
4397
|
+
renderCall: (...args: any[]) => select("renderCall", args),
|
|
4398
|
+
renderResult: (...args: any[]) => select("renderResult", args),
|
|
4399
|
+
};
|
|
4351
4400
|
});
|
|
4352
4401
|
};
|
|
4353
4402
|
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { ProcessTerminal, TuiAltScreen, sliceByColumn, stripTerminalSequences, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
type Span = [number, number];
|
|
5
|
+
// Session-local APC annotations travel through opaque Container wrappers as
|
|
6
|
+
// zero-width text. They are removed before any terminal write or clipboard use.
|
|
7
|
+
const prefix = `\x1b_ppt-select-${randomBytes(6).toString("hex")}:`;
|
|
8
|
+
const suffix = "\x1b\\";
|
|
9
|
+
const marker = new RegExp(prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "([0-9,;]*)\x1b\\\\", "g");
|
|
10
|
+
let enabled = false;
|
|
11
|
+
export const selectionMetadataActive = () => enabled;
|
|
12
|
+
export const removeSelectionMetadata = (text: string): string => text.includes(prefix) ? text.replace(marker, "") : text;
|
|
13
|
+
const subtract = (spans: Span[], start: number, end: number): Span[] => spans.flatMap(([a, b]) => {
|
|
14
|
+
if (end <= a || start >= b) return [[a, b] as Span];
|
|
15
|
+
const rest: Span[] = [];
|
|
16
|
+
if (a < start) rest.push([a, start]);
|
|
17
|
+
if (end < b) rest.push([end, b]);
|
|
18
|
+
return rest;
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
type Annotation = { width: number; excluded: Span[] };
|
|
22
|
+
const decoded = new Map<string, Annotation>();
|
|
23
|
+
const encoded = new Map<string, string>();
|
|
24
|
+
function annotation(payload: string): Annotation | undefined {
|
|
25
|
+
const cached = decoded.get(payload);
|
|
26
|
+
if (cached) return cached;
|
|
27
|
+
const fields = payload.split(";");
|
|
28
|
+
const width = Number(fields.shift());
|
|
29
|
+
if (!Number.isSafeInteger(width) || width < 0) return undefined;
|
|
30
|
+
const excluded: Span[] = [];
|
|
31
|
+
let cursor = 0;
|
|
32
|
+
for (const field of fields) {
|
|
33
|
+
if (!field) continue;
|
|
34
|
+
const values = field.split(",").map(Number);
|
|
35
|
+
if (values.length !== 2) return undefined;
|
|
36
|
+
const [a, b] = values;
|
|
37
|
+
if (!Number.isSafeInteger(a) || !Number.isSafeInteger(b) || a < cursor || b < a || b > width) return undefined;
|
|
38
|
+
if (cursor < a) excluded.push([cursor, a]);
|
|
39
|
+
cursor = b;
|
|
40
|
+
}
|
|
41
|
+
if (cursor < width) excluded.push([cursor, width]);
|
|
42
|
+
const result = { width, excluded };
|
|
43
|
+
if (decoded.size >= 1024) decoded.delete(decoded.keys().next().value!);
|
|
44
|
+
decoded.set(payload, result);
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Mark only known plugin decoration. Inner annotations are composed, never
|
|
49
|
+
* inferred from glyphs, so actual box art and code strings remain untouched. */
|
|
50
|
+
export function selectableRow(line: string, width: number, keep: Span[]): string {
|
|
51
|
+
if (!enabled || line.includes("\x1b_G") || line.includes("\x1b]1337;File=")) return line;
|
|
52
|
+
const payload = width + (keep.length ? ";" + keep.map(([a, b]) => a + "," + b).join(";") : "");
|
|
53
|
+
let tag = encoded.get(payload);
|
|
54
|
+
if (!tag) {
|
|
55
|
+
tag = prefix + payload + suffix;
|
|
56
|
+
if (encoded.size >= 1024) encoded.delete(encoded.keys().next().value!);
|
|
57
|
+
encoded.set(payload, tag);
|
|
58
|
+
}
|
|
59
|
+
return tag + line;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrollBox(root: any, owner: any): any {
|
|
63
|
+
if (!root) return undefined;
|
|
64
|
+
if (root.scrollView === owner) return root;
|
|
65
|
+
for (const child of root.children ?? []) {
|
|
66
|
+
const found = scrollBox(child, owner);
|
|
67
|
+
if (found) return found;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function viewportRows(root: any, first: number, last: number, offsetX = 0, offsetY = 0, offscreen = false): Map<number, Array<{ line: string; x: number; left: number; right: number }>> {
|
|
72
|
+
const result = new Map<number, Array<{ line: string; x: number; left: number; right: number }>>();
|
|
73
|
+
const visit = (box: any) => {
|
|
74
|
+
if (!box?.rect || !box.clip) return;
|
|
75
|
+
const vertical = offscreen ? box.rect : box.clip;
|
|
76
|
+
if (vertical.y + offsetY > last || vertical.y + vertical.height + offsetY <= first) return;
|
|
77
|
+
if (box.children?.length) { for (const child of box.children) visit(child); return; }
|
|
78
|
+
if (!box.lines) return;
|
|
79
|
+
const start = Math.max(first - offsetY, box.rect.y, vertical.y);
|
|
80
|
+
const end = Math.min(last + 1 - offsetY, box.rect.y + box.rect.height, vertical.y + vertical.height);
|
|
81
|
+
for (let row = start; row < end; row++) {
|
|
82
|
+
const line = box.lines[row - box.rect.y + (box.lineOffset ?? 0)];
|
|
83
|
+
if (line === undefined) continue;
|
|
84
|
+
const records = result.get(row + offsetY) ?? [];
|
|
85
|
+
records.push({ line, x: box.rect.x + offsetX, left: Math.max(box.rect.x, box.clip.x) + offsetX,
|
|
86
|
+
right: Math.min(box.rect.x + box.rect.width, box.clip.x + box.clip.width) + offsetX });
|
|
87
|
+
result.set(row + offsetY, records);
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
visit(root);
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
type Surface = { row: number; col: number; width: number; height: number; lines?: string[] };
|
|
95
|
+
|
|
96
|
+
/** Capture rows during the native compositor's single render, not by rendering
|
|
97
|
+
* a component again at copy time. Restore the exact previous own descriptor. */
|
|
98
|
+
function captureRender(target: any, name: string, receive: (rows: string[]) => void): () => void {
|
|
99
|
+
const method = target?.[name];
|
|
100
|
+
if (typeof method !== "function") return () => {};
|
|
101
|
+
const own = Object.getOwnPropertyDescriptor(target, name);
|
|
102
|
+
const capture = function (this: any, ...args: any[]) {
|
|
103
|
+
const result = method.apply(this, args);
|
|
104
|
+
if (Array.isArray(result)) receive(result);
|
|
105
|
+
return result;
|
|
106
|
+
};
|
|
107
|
+
Object.defineProperty(target, name, { configurable: true, writable: true, value: capture });
|
|
108
|
+
return () => {
|
|
109
|
+
if (target[name] !== capture) return;
|
|
110
|
+
if (own) Object.defineProperty(target, name, own);
|
|
111
|
+
else delete target[name];
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function excludedColumns(line: string): Span[] {
|
|
116
|
+
if (!line.includes(prefix)) return [];
|
|
117
|
+
const excluded: Span[] = [];
|
|
118
|
+
for (const match of line.matchAll(marker)) {
|
|
119
|
+
const tag = annotation(match[1]);
|
|
120
|
+
if (!tag) continue;
|
|
121
|
+
// A start annotation survives native right-edge clipping. Prefixes are
|
|
122
|
+
// normally only outer annotations and padding, so this stays inexpensive.
|
|
123
|
+
const origin = visibleWidth(line.slice(0, match.index));
|
|
124
|
+
for (const [a, b] of tag.excluded) excluded.push([origin + a, origin + b]);
|
|
125
|
+
}
|
|
126
|
+
return excluded;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function installSelectionCopy(): () => void {
|
|
130
|
+
const screen = TuiAltScreen.prototype as any;
|
|
131
|
+
const terminal = ProcessTerminal.prototype as any;
|
|
132
|
+
const key = Symbol.for("pretty-tui.selection-ranges");
|
|
133
|
+
if (screen[key]) return () => {};
|
|
134
|
+
const originalCopy = screen.getActiveSelectionText;
|
|
135
|
+
const originalWrite = terminal.write;
|
|
136
|
+
const originalOverlays = screen.compositeOverlays;
|
|
137
|
+
const originalFlashes = screen.compositeFlashes;
|
|
138
|
+
if (typeof originalCopy !== "function" || typeof originalWrite !== "function" ||
|
|
139
|
+
typeof originalOverlays !== "function" || typeof originalFlashes !== "function") return () => {};
|
|
140
|
+
const overlays = new WeakMap<object, Surface[]>();
|
|
141
|
+
const flashes = new WeakMap<object, Surface[]>();
|
|
142
|
+
const compositeOverlays = function (this: any, lines: string[], width: number, height: number) {
|
|
143
|
+
if (!this.overlayStack?.length) {
|
|
144
|
+
overlays.delete(this);
|
|
145
|
+
return originalOverlays.call(this, lines, width, height);
|
|
146
|
+
}
|
|
147
|
+
const captured: string[][] = [];
|
|
148
|
+
const restore = captureRender(this, "resolveFakeCursors", rows => captured.push(rows));
|
|
149
|
+
let result: string[];
|
|
150
|
+
try { result = originalOverlays.call(this, lines, width, height); }
|
|
151
|
+
finally { restore(); }
|
|
152
|
+
overlays.set(this, (this.renderedOverlayLayouts ?? []).map((box: Surface, i: number) => ({
|
|
153
|
+
row: box.row, col: box.col, width: box.width, height: box.height, lines: captured[i],
|
|
154
|
+
})));
|
|
155
|
+
return result;
|
|
156
|
+
};
|
|
157
|
+
const compositeFlashes = function (this: any, lines: string[], width: number, height: number) {
|
|
158
|
+
if (Array.isArray(this.flashes?.entries) && this.flashes.entries.length === 0) {
|
|
159
|
+
flashes.delete(this);
|
|
160
|
+
return originalFlashes.call(this, lines, width, height);
|
|
161
|
+
}
|
|
162
|
+
let captured: string[] = [];
|
|
163
|
+
const restore = captureRender(this.flashes, "render", rows => { captured = rows; });
|
|
164
|
+
let result: string[];
|
|
165
|
+
try { result = originalFlashes.call(this, lines, width, height); }
|
|
166
|
+
finally { restore(); }
|
|
167
|
+
flashes.set(this, captured.slice(-height).map((line, row) => ({
|
|
168
|
+
row, col: width - visibleWidth(line), width: visibleWidth(line), height: 1, lines: [line],
|
|
169
|
+
})));
|
|
170
|
+
return result;
|
|
171
|
+
};
|
|
172
|
+
const copy = function (this: any): string | undefined {
|
|
173
|
+
const selection = this.getSelectionBounds();
|
|
174
|
+
if (!selection) return undefined;
|
|
175
|
+
const inScroll = Boolean(selection.start.scrollView);
|
|
176
|
+
const scroll = inScroll ? scrollBox(this.currentLayout?.root, selection.start.scrollView) : undefined;
|
|
177
|
+
const lines: string[] | undefined = inScroll ? scroll?.scrollContentLines : this.previousScreen;
|
|
178
|
+
if (!lines) return originalCopy.call(this);
|
|
179
|
+
const viewport = inScroll
|
|
180
|
+
? viewportRows(scroll?.children?.[0], selection.start.row, selection.end.row,
|
|
181
|
+
-scroll.rect.x, selection.start.scrollView.scrollTop - scroll.rect.y, true)
|
|
182
|
+
: viewportRows(this.currentLayout?.root, selection.start.row, selection.end.row);
|
|
183
|
+
const surfaces: Surface[] = [];
|
|
184
|
+
if (!inScroll) {
|
|
185
|
+
const jump = this.scrollToEndIndicatorRect;
|
|
186
|
+
if (jump) surfaces.push({ row: jump.row, col: jump.column, width: jump.width, height: 1 });
|
|
187
|
+
surfaces.push(...overlays.get(this) ?? [], ...flashes.get(this) ?? []);
|
|
188
|
+
}
|
|
189
|
+
const copied: string[] = [];
|
|
190
|
+
for (let row = selection.start.row; row <= selection.end.row; row++) {
|
|
191
|
+
const line = lines[row] ?? "";
|
|
192
|
+
const columns = this.getSelectionColumns(line, row, selection);
|
|
193
|
+
// Composited/sliced metadata may no longer describe the full fragment.
|
|
194
|
+
// Prefer current raw leaves whenever layout information is available.
|
|
195
|
+
let excluded = this.currentLayout ? [] as Span[] : excludedColumns(line);
|
|
196
|
+
let annotated = line.includes(prefix);
|
|
197
|
+
const paint = (source: string | undefined, x: number, left: number, right: number) => {
|
|
198
|
+
if (right <= left) return;
|
|
199
|
+
// A later surface replaces lower decoration, including when the later
|
|
200
|
+
// surface is ordinary text with no private annotation at all.
|
|
201
|
+
excluded = subtract(excluded, left, right);
|
|
202
|
+
if (!source?.includes(prefix)) return;
|
|
203
|
+
annotated = true;
|
|
204
|
+
for (const [a, b] of excludedColumns(source)) {
|
|
205
|
+
const start = Math.max(left, x + a), end = Math.min(right, x + b);
|
|
206
|
+
if (end > start) excluded.push([start, end]);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
for (const record of viewport?.get(row) ?? []) paint(record.line, record.x, record.left, record.right);
|
|
210
|
+
for (const surface of surfaces) {
|
|
211
|
+
if (row < surface.row || row >= surface.row + surface.height) continue;
|
|
212
|
+
paint(surface.lines?.[row - surface.row], surface.col,
|
|
213
|
+
Math.max(0, surface.col), Math.min(visibleWidth(line), surface.col + surface.width));
|
|
214
|
+
}
|
|
215
|
+
let spans: Span[] = columns.end > columns.start ? [[columns.start, columns.end]] : [];
|
|
216
|
+
for (const [a, b] of excluded) spans = subtract(spans, a, b);
|
|
217
|
+
if (annotated && !spans.length) continue;
|
|
218
|
+
copied.push(spans.map(([a, b]) => stripTerminalSequences(sliceByColumn(line, a, Math.max(0, b - a), true))).join("").trimEnd());
|
|
219
|
+
}
|
|
220
|
+
const result = copied.join("\n");
|
|
221
|
+
return result.length ? result : undefined;
|
|
222
|
+
};
|
|
223
|
+
const write = function (this: any, data: string) { return originalWrite.call(this, removeSelectionMetadata(data)); };
|
|
224
|
+
const methods = [
|
|
225
|
+
[screen, "getActiveSelectionText", originalCopy, copy],
|
|
226
|
+
[screen, "compositeOverlays", originalOverlays, compositeOverlays],
|
|
227
|
+
[screen, "compositeFlashes", originalFlashes, compositeFlashes],
|
|
228
|
+
[terminal, "write", originalWrite, write],
|
|
229
|
+
] as const;
|
|
230
|
+
const originalOwn = methods.map(([target, name]) => Object.getOwnPropertyDescriptor(target, name));
|
|
231
|
+
screen[key] = { methods };
|
|
232
|
+
for (const [target, name, , patched] of methods) target[name] = patched;
|
|
233
|
+
enabled = true;
|
|
234
|
+
return () => {
|
|
235
|
+
enabled = false;
|
|
236
|
+
decoded.clear();
|
|
237
|
+
encoded.clear();
|
|
238
|
+
methods.forEach(([target, name, , patched], i) => {
|
|
239
|
+
if (target[name] !== patched) return;
|
|
240
|
+
if (originalOwn[i]) Object.defineProperty(target, name, originalOwn[i]!);
|
|
241
|
+
else delete target[name];
|
|
242
|
+
});
|
|
243
|
+
if (screen[key]?.methods === methods) delete screen[key];
|
|
244
|
+
};
|
|
245
|
+
}
|
|
@@ -7,7 +7,11 @@ import type { NestedTools } from "./nested-tools.js";
|
|
|
7
7
|
// Tool content and hit regions are independent of activity grouping. The host
|
|
8
8
|
// decides whether to decorate this projection with a clean activity tree.
|
|
9
9
|
export const SPECIALIZED_TOOL_NAMES = new Set(["read", "bash", "edit", "write", "grep", "find", "ls"]);
|
|
10
|
+
// A third-party renderer can also use renderShell: "self". The shell style is
|
|
11
|
+
// not proof that it is one of our specialized built-in renderers.
|
|
12
|
+
export const THIRD_PARTY_TOOL = Symbol("pretty-tui.third-party-tool");
|
|
10
13
|
export const isThirdPartyTool = (owner: any): boolean =>
|
|
14
|
+
Boolean(owner.toolDefinition?.[THIRD_PARTY_TOOL]) ||
|
|
11
15
|
!SPECIALIZED_TOOL_NAMES.has(owner.toolName) || owner.toolDefinition?.renderShell !== "self";
|
|
12
16
|
|
|
13
17
|
export const toolLabel = (toolName: string, label?: string): string => label || toolName;
|
|
@@ -29,7 +33,9 @@ const conciseThirdPartyArgs = (args: any): string => {
|
|
|
29
33
|
const safeValue = String(value)
|
|
30
34
|
.replace(/(https?:\/\/)[^\s/@:]+:[^\s/@]+@/giu, "$1…:…@")
|
|
31
35
|
.replace(/([?&](?:token|secret|password|api[_-]?key)=)[^&\s]+/giu, "$1…");
|
|
32
|
-
|
|
36
|
+
// Argument previews are plain text. The truncator emits SGR resets even
|
|
37
|
+
// for unstyled input; remove those before applying the shared muted role.
|
|
38
|
+
const rendered = stripTerminalSequences(truncateToWidth(stripTerminalSequences(safeValue), 36, "…"));
|
|
33
39
|
return `${key}=${rendered}`;
|
|
34
40
|
});
|
|
35
41
|
return entries.join(" · ");
|
|
@@ -45,7 +51,7 @@ const thirdPartyResultSummary = (component: any): string => {
|
|
|
45
51
|
const newline = boundedText.indexOf("\n");
|
|
46
52
|
const boundedFirstLine = boundedText.slice(0, newline < 0 ? boundedText.length : newline);
|
|
47
53
|
const firstLine = stripTerminalSequences(boundedFirstLine).trim();
|
|
48
|
-
if (firstLine) return truncateToWidth(firstLine, 72, "…");
|
|
54
|
+
if (firstLine) return stripTerminalSequences(truncateToWidth(firstLine, 72, "…"));
|
|
49
55
|
if (component.result) return component.result.isError ? "Failed" : "Done";
|
|
50
56
|
return component.executionStarted ? "Running…" : "Pending";
|
|
51
57
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-pretty-tui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.1",
|
|
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>",
|
|
@@ -15,7 +15,13 @@
|
|
|
15
15
|
},
|
|
16
16
|
"scripts": {
|
|
17
17
|
"typecheck": "tsc -p tsconfig.json",
|
|
18
|
-
"test": "npm run typecheck && node tests/regression.mjs"
|
|
18
|
+
"test": "npm run typecheck && node tests/regression.mjs && node tests/coexistence.mjs && node tests/selection-ranges.mjs && node tests/selection-options.mjs",
|
|
19
|
+
"test:selection-pty": "python3 tests/selection-pty.py",
|
|
20
|
+
"bench:selection": "node tests/selection-benchmark.mjs",
|
|
21
|
+
"test:selection-cli": "python3 tests/selection-cli.py",
|
|
22
|
+
"bench:selection-frame": "node tests/selection-frame-benchmark.mjs",
|
|
23
|
+
"bench:selection-memory": "node --expose-gc tests/selection-memory-benchmark.mjs",
|
|
24
|
+
"test:selection-gc": "node --expose-gc tests/selection-gc.mjs && node --expose-gc tests/selection-reload-gc.mjs"
|
|
19
25
|
},
|
|
20
26
|
"keywords": [
|
|
21
27
|
"pi-package",
|
|
@@ -39,8 +45,8 @@
|
|
|
39
45
|
"@earendil-works/pi-tui": "*"
|
|
40
46
|
},
|
|
41
47
|
"devDependencies": {
|
|
42
|
-
"@earendil-works/pi-coding-agent": "1.
|
|
43
|
-
"@earendil-works/pi-tui": "1.
|
|
48
|
+
"@earendil-works/pi-coding-agent": "1.4.0",
|
|
49
|
+
"@earendil-works/pi-tui": "1.4.0",
|
|
44
50
|
"@types/node": "^24.0.0",
|
|
45
51
|
"jiti": "^2.7.0",
|
|
46
52
|
"typescript": "^5.9.0"
|