pi-pretty-tui 0.3.0 → 0.3.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 +41 -87
- package/README.zh-CN.md +40 -86
- package/assets/screenshot.png +0 -0
- package/extensions/full-frame.ts +16 -5
- package/extensions/index.ts +192 -87
- package/extensions/markdown-emphasis.ts +87 -0
- package/extensions/markdown-spacing.ts +123 -0
- package/extensions/selection-ranges.ts +245 -0
- package/extensions/tool-presentation.ts +8 -2
- package/package.json +10 -4
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { stripTerminalSequences } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transcript Markdown spacing policy. This is the only place that decides list
|
|
5
|
+
* spacing; change the table below to tune it. It applies to every list the same
|
|
6
|
+
* way: unordered or ordered, short or wrapped, nested, task, loose or complex.
|
|
7
|
+
*
|
|
8
|
+
* - Pi's Markdown source and parsed tokens are never changed. Items and token
|
|
9
|
+
* lists are copied, because Pi caches its parsed tokens between renders.
|
|
10
|
+
* - Only blank rows change. Text, emphasis, wrapping and markers stay Pi's.
|
|
11
|
+
* - A boundary that already has a blank row, from a source blank line or from
|
|
12
|
+
* Pi itself, does not get a second one.
|
|
13
|
+
* - Callers apply this to transcript Markdown only, so overlays stay native.
|
|
14
|
+
*/
|
|
15
|
+
export const MARKDOWN_GAP_ROWS = {
|
|
16
|
+
/** A paragraph directly followed by a list. */
|
|
17
|
+
beforeList: 1,
|
|
18
|
+
/** A list directly followed by another block, such as a heading. */
|
|
19
|
+
afterList: 1,
|
|
20
|
+
/** Sibling items, at every nesting depth. */
|
|
21
|
+
betweenItems: 1,
|
|
22
|
+
/** Blocks inside one item, such as its text and its nested list. */
|
|
23
|
+
withinItem: 1,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
export interface MarkdownBlockToken {
|
|
27
|
+
type: string;
|
|
28
|
+
raw?: string;
|
|
29
|
+
text?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface MarkdownListItemToken {
|
|
33
|
+
tokens: MarkdownBlockToken[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface MarkdownListToken extends MarkdownBlockToken {
|
|
37
|
+
items: MarkdownListItemToken[];
|
|
38
|
+
loose: boolean;
|
|
39
|
+
start?: number | "";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const blankRows = (count: number): string[] => Array.from({ length: count }, () => "");
|
|
43
|
+
|
|
44
|
+
const isBlankRow = (row: string): boolean => stripTerminalSequences(row).trim() === "";
|
|
45
|
+
|
|
46
|
+
/** Rows to add after a top-level or blockquote block that Pi leaves unspaced. */
|
|
47
|
+
export function gapRowsAfterBlock(type: string | undefined, nextType: string | undefined): string[] {
|
|
48
|
+
if (type === "paragraph" && nextType === "list") return blankRows(MARKDOWN_GAP_ROWS.beforeList);
|
|
49
|
+
if (type === "list" && nextType !== undefined && nextType !== "space") {
|
|
50
|
+
return blankRows(MARKDOWN_GAP_ROWS.afterList);
|
|
51
|
+
}
|
|
52
|
+
return [];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type ItemTokenOutput = "none" | "blank" | "content";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* What a token inside a list item emits. Pi draws a `checkbox` as part of the
|
|
59
|
+
* item marker, so it emits nothing; treating it as content would part the marker
|
|
60
|
+
* from its text. An empty `text` still emits a row, but that row is blank.
|
|
61
|
+
*/
|
|
62
|
+
function itemTokenOutput(token: MarkdownBlockToken): ItemTokenOutput {
|
|
63
|
+
if (token.type === "checkbox" || token.type === "def") return "none";
|
|
64
|
+
if (token.type === "space") return "blank";
|
|
65
|
+
if (token.type === "text" || token.type === "paragraph") {
|
|
66
|
+
return token.text?.trim() ? "content" : "blank";
|
|
67
|
+
}
|
|
68
|
+
if (token.type === "html") return token.raw?.trim() ? "content" : "blank";
|
|
69
|
+
return "content";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Returns a copy of the item with a gap between adjacent content blocks and with
|
|
74
|
+
* no trailing blank rows. Because no item ends blank, nested lists never end
|
|
75
|
+
* blank either, so the gap after them cannot double. Pi's blocks add their own
|
|
76
|
+
* trailing gap only when told a next token type, and item blocks are not told.
|
|
77
|
+
*/
|
|
78
|
+
function withItemBlockGaps(item: MarkdownListItemToken): MarkdownListItemToken {
|
|
79
|
+
const tokens: MarkdownBlockToken[] = [];
|
|
80
|
+
let previousOutput: ItemTokenOutput = "none";
|
|
81
|
+
for (const token of item.tokens) {
|
|
82
|
+
const output = itemTokenOutput(token);
|
|
83
|
+
if (previousOutput === "content" && output === "content") {
|
|
84
|
+
for (let row = 0; row < MARKDOWN_GAP_ROWS.withinItem; row += 1) tokens.push({ type: "space" });
|
|
85
|
+
}
|
|
86
|
+
if (output !== "none") previousOutput = output;
|
|
87
|
+
tokens.push(token);
|
|
88
|
+
}
|
|
89
|
+
while (tokens.length > 0 && itemTokenOutput(tokens[tokens.length - 1]) !== "content") tokens.pop();
|
|
90
|
+
return { ...item, tokens };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Joins rendered items, skipping the gap where a blank row already separates them. */
|
|
94
|
+
function joinItems(renderedItems: readonly string[][]): string[] {
|
|
95
|
+
const rows: string[] = [];
|
|
96
|
+
for (const itemRows of renderedItems) {
|
|
97
|
+
const previousRow = rows[rows.length - 1];
|
|
98
|
+
const nextRow = itemRows[0];
|
|
99
|
+
if (previousRow !== undefined && nextRow !== undefined && !isBlankRow(previousRow) && !isBlankRow(nextRow)) {
|
|
100
|
+
rows.push(...blankRows(MARKDOWN_GAP_ROWS.betweenItems));
|
|
101
|
+
}
|
|
102
|
+
rows.push(...itemRows);
|
|
103
|
+
}
|
|
104
|
+
return rows;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Renders a list one item at a time through Pi's own list renderer, which
|
|
109
|
+
* re-enters this policy for nested lists. `loose` is cleared so that Pi's
|
|
110
|
+
* loose-list gap cannot add to the gap chosen here.
|
|
111
|
+
*/
|
|
112
|
+
export function renderSpacedList(
|
|
113
|
+
list: MarkdownListToken,
|
|
114
|
+
renderList: (singleItemList: MarkdownListToken) => string[],
|
|
115
|
+
): string[] {
|
|
116
|
+
const start = typeof list.start === "number" ? list.start : 1;
|
|
117
|
+
return joinItems(list.items.map((item, index) => renderList({
|
|
118
|
+
...list,
|
|
119
|
+
start: start + index,
|
|
120
|
+
loose: false,
|
|
121
|
+
items: [withItemBlockGaps(item)],
|
|
122
|
+
})));
|
|
123
|
+
}
|
|
@@ -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.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>",
|
|
@@ -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 && node tests/markdown-spacing.mjs && node tests/markdown-emphasis.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.5.0",
|
|
49
|
+
"@earendil-works/pi-tui": "1.5.0",
|
|
44
50
|
"@types/node": "^24.0.0",
|
|
45
51
|
"jiti": "^2.7.0",
|
|
46
52
|
"typescript": "^5.9.0"
|