pi-open-tui 0.3.14 → 0.4.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 +72 -5
- package/README.zh-CN.md +72 -5
- package/assets/preview_dashboard_1.png +0 -0
- package/extensions/open-tui/config.ts +26 -2
- package/extensions/open-tui/copy-buttons.ts +363 -0
- package/extensions/open-tui/editor.ts +322 -122
- package/extensions/open-tui/footer.ts +13 -11
- package/extensions/open-tui/git.ts +10 -32
- package/extensions/open-tui/header.ts +133 -244
- package/extensions/open-tui/icons.ts +9 -0
- package/extensions/open-tui/index.ts +45 -20
- package/extensions/open-tui/runtime.ts +10 -11
- package/extensions/open-tui/session-lifecycle.ts +23 -43
- package/extensions/open-tui/settings-command.ts +66 -45
- package/extensions/open-tui/state.ts +11 -14
- package/extensions/open-tui/tool-preview.ts +167 -0
- package/extensions/open-tui/tool-view.ts +418 -0
- package/extensions/open-tui/utils.ts +3 -3
- package/package.json +11 -15
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { copyToClipboard, UserMessageComponent, type MarkdownTransformContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
compositeTuiLine,
|
|
4
|
+
Markdown,
|
|
5
|
+
Marked,
|
|
6
|
+
truncateToWidth,
|
|
7
|
+
TuiAltScreen,
|
|
8
|
+
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
10
|
+
type MarkdownTheme,
|
|
11
|
+
type Token,
|
|
12
|
+
type TUI,
|
|
13
|
+
type TuiMouseEvent,
|
|
14
|
+
type TuiMouseEventResult,
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
16
|
+
|
|
17
|
+
const LABEL = "[Copy]";
|
|
18
|
+
// Below this width a frame plus label would crowd the code, so Pi's plain fences are kept.
|
|
19
|
+
const MIN_FRAME_WIDTH = 8;
|
|
20
|
+
// Zero-width APC sequence: survives wrapping and padding, and marks the label column before we strip it.
|
|
21
|
+
const MARKER = "\x1b_open-tui:cb\x07";
|
|
22
|
+
const PATCH_KEY = Symbol.for("pi-open-tui.copy-buttons");
|
|
23
|
+
// pi-pretty-tui draws its own code-block [Copy]; patching the same methods twice would fight.
|
|
24
|
+
const PRETTY_TUI_KEY = Symbol.for("pretty-tui.code-blocks");
|
|
25
|
+
// Pi only synthesizes `click` for a zero-movement release, so allow a little jitter.
|
|
26
|
+
const RELEASE_TOLERANCE = 2;
|
|
27
|
+
|
|
28
|
+
export interface CopyRegion {
|
|
29
|
+
row: number;
|
|
30
|
+
col: number;
|
|
31
|
+
width: number;
|
|
32
|
+
text: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface Press {
|
|
36
|
+
owner: object;
|
|
37
|
+
region: CopyRegion;
|
|
38
|
+
x: number;
|
|
39
|
+
y: number;
|
|
40
|
+
onCopy: (text: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface RawMouseEvent {
|
|
44
|
+
button: number;
|
|
45
|
+
x: number;
|
|
46
|
+
y: number;
|
|
47
|
+
release: boolean;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface AltScreenInternals {
|
|
51
|
+
handleMouseEvent(event: RawMouseEvent): void;
|
|
52
|
+
createMouseEvent(type: TuiMouseEvent["type"], button: number, x: number, y: number): TuiMouseEvent;
|
|
53
|
+
resetTextSelection(): void;
|
|
54
|
+
requestRender(): void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Pi's private fields; checked at runtime because they can change between releases.
|
|
58
|
+
interface MarkdownInternals {
|
|
59
|
+
text?: unknown;
|
|
60
|
+
theme: MarkdownTheme;
|
|
61
|
+
render(width: number): string[];
|
|
62
|
+
renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: unknown): string[];
|
|
63
|
+
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
64
|
+
}
|
|
65
|
+
interface UserMessageInternals {
|
|
66
|
+
text?: unknown;
|
|
67
|
+
outputPad?: unknown;
|
|
68
|
+
render(width: number): string[];
|
|
69
|
+
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Set by our Markdown transformer while a transcript Markdown renders. */
|
|
73
|
+
let renderScope: { frame: boolean; label: boolean; ordinal: number; blocks: { text: string; ordinal: number }[] } | undefined;
|
|
74
|
+
let press: Press | undefined;
|
|
75
|
+
const markdownRegions = new WeakMap<object, { lines: readonly string[]; regions: CopyRegion[] }>();
|
|
76
|
+
const userRegions = new WeakMap<object, CopyRegion>();
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Markdown transformer that only records the message kind; Pi calls it during Markdown.render.
|
|
80
|
+
* Thinking blocks keep Pi's fences. Streaming replies get the frame but no label: it would copy unfinished code.
|
|
81
|
+
*/
|
|
82
|
+
export function markCopyScope(markdown: string, context: MarkdownTransformContext): string {
|
|
83
|
+
if (renderScope) {
|
|
84
|
+
renderScope.frame = context.messageType !== "assistant-thinking";
|
|
85
|
+
renderScope.label = renderScope.frame && !context.isStreaming;
|
|
86
|
+
}
|
|
87
|
+
return markdown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Rounded frame with the language and the label in the top border, like pi-pretty-tui:
|
|
92
|
+
* `╭─ ts ───── [Copy] ╮`. It replaces the two fence rows, so the block keeps its height.
|
|
93
|
+
*/
|
|
94
|
+
export function renderFramedCode(
|
|
95
|
+
theme: MarkdownTheme,
|
|
96
|
+
code: string,
|
|
97
|
+
lang: string | undefined,
|
|
98
|
+
width: number,
|
|
99
|
+
label: string | undefined,
|
|
100
|
+
border: (text: string) => string = theme.codeBlockBorder,
|
|
101
|
+
): string[] {
|
|
102
|
+
const inner = width - 4;
|
|
103
|
+
const highlighted = theme.highlightCode?.(code, lang) ?? code.split("\n").map((line) => theme.codeBlock(line));
|
|
104
|
+
const rows = highlighted.flatMap((line) => {
|
|
105
|
+
const wrapped = wrapTextWithAnsi(line, inner);
|
|
106
|
+
return wrapped.length > 0 ? wrapped : [""];
|
|
107
|
+
});
|
|
108
|
+
const suffix = label === undefined ? "" : ` ${LABEL} `;
|
|
109
|
+
const name = truncateToWidth(lang?.trim().split(/\s+/, 1)[0] || "code", Math.max(1, width - 6 - visibleWidth(suffix)), "…");
|
|
110
|
+
const prefix = `╭─ ${name} `;
|
|
111
|
+
const widest = Math.max(0, ...rows.map((row) => visibleWidth(row)));
|
|
112
|
+
const frameWidth = Math.min(width, Math.max(widest + 4, visibleWidth(prefix) + 3 + visibleWidth(suffix) + 1));
|
|
113
|
+
const fill = Math.max(0, frameWidth - visibleWidth(prefix) - visibleWidth(suffix) - 1);
|
|
114
|
+
const top = border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
|
|
115
|
+
+ (label === undefined ? "" : ` ${MARKER}${label} `) + border("╮");
|
|
116
|
+
const body = rows.map((row) => border("│ ") + row + " ".repeat(Math.max(0, frameWidth - 4 - visibleWidth(row))) + border(" │"));
|
|
117
|
+
return [top, ...body, border(`╰${"─".repeat(frameWidth - 2)}╯`)];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Hit-test press/drag/release on recorded regions. Anything else returns undefined so Pi keeps its selection. */
|
|
121
|
+
export function handleCopyMouse(
|
|
122
|
+
owner: object,
|
|
123
|
+
regions: readonly CopyRegion[],
|
|
124
|
+
event: TuiMouseEvent,
|
|
125
|
+
onCopy: (text: string) => void,
|
|
126
|
+
): TuiMouseEventResult | undefined {
|
|
127
|
+
if (event.type === "press") {
|
|
128
|
+
if (press?.owner === owner) press = undefined;
|
|
129
|
+
if (event.button !== "left" || event.shift || event.alt || event.ctrl) return undefined;
|
|
130
|
+
const region = regions.find((r) => event.y === r.row && event.x >= r.col && event.x < r.col + r.width);
|
|
131
|
+
if (region) press = { owner, region, x: event.screenX, y: event.screenY, onCopy };
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
if (press?.owner !== owner || (event.type !== "drag" && event.type !== "release")) return undefined;
|
|
135
|
+
const pending = press;
|
|
136
|
+
const nearby = event.screenY === pending.y && Math.abs(event.screenX - pending.x) <= RELEASE_TOLERANCE;
|
|
137
|
+
const primary = event.button === "left" || (event.type === "release" && event.button === "none");
|
|
138
|
+
if (!nearby || !primary || event.shift || event.alt || event.ctrl) {
|
|
139
|
+
press = undefined;
|
|
140
|
+
return undefined;
|
|
141
|
+
}
|
|
142
|
+
if (event.type === "drag") return undefined;
|
|
143
|
+
press = undefined;
|
|
144
|
+
pending.onCopy(pending.region.text);
|
|
145
|
+
return { handled: true, render: false };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Markdown.render normalizes tabs to three spaces before lexing, so token text loses them.
|
|
150
|
+
* Match the normalized tokens back to source lines: lexing raw lists would expand their tabs first.
|
|
151
|
+
*/
|
|
152
|
+
export function restoreTabs(source: string, ordinal: number, rendered: string): string {
|
|
153
|
+
if (!source.includes("\t")) return rendered;
|
|
154
|
+
const sourceLines = source.replace(/\r\n?/g, "\n").split("\n");
|
|
155
|
+
const normalized = sourceLines.map((line) => line.replace(/\t/g, " "));
|
|
156
|
+
const blocks: { raw: string; text: string }[] = [];
|
|
157
|
+
const parser = new Marked();
|
|
158
|
+
parser.walkTokens(parser.lexer(normalized.join("\n")), (token) => {
|
|
159
|
+
if (token.type === "code") blocks.push({ raw: token.raw, text: token.text });
|
|
160
|
+
});
|
|
161
|
+
const block = blocks[ordinal];
|
|
162
|
+
if (!block || block.text !== rendered || rendered === "") return rendered;
|
|
163
|
+
const rawLines = block.raw.replace(/\n+$/, "").split("\n");
|
|
164
|
+
let marker = "\ue000";
|
|
165
|
+
while (source.includes(marker)) marker += "\ue000";
|
|
166
|
+
const expected = rendered.split("\n");
|
|
167
|
+
expected[0] += marker;
|
|
168
|
+
for (let start = 0; start < sourceLines.length; start++) {
|
|
169
|
+
// List/quote parsing removes container prefixes, but leaves the code's raw line suffixes intact.
|
|
170
|
+
if (!rawLines.every((raw, offset) => normalized[start + offset]?.endsWith(raw))) continue;
|
|
171
|
+
const body = start + (/^ {0,3}(?:`{3,}|~{3,})/.test(block.raw) ? 1 : 0);
|
|
172
|
+
const probe = normalized.slice();
|
|
173
|
+
probe[body] += marker;
|
|
174
|
+
let index = 0;
|
|
175
|
+
let matched = false;
|
|
176
|
+
// Confirm the source row belongs to this token, not identical code in HTML or an earlier block.
|
|
177
|
+
parser.walkTokens(parser.lexer(probe.join("\n")), (token) => {
|
|
178
|
+
if (token.type === "code" && index++ === ordinal) matched = token.text === expected.join("\n");
|
|
179
|
+
});
|
|
180
|
+
if (!matched) continue;
|
|
181
|
+
const restored = rendered.split("\n").map((text, offset) => {
|
|
182
|
+
const line = sourceLines[body + offset];
|
|
183
|
+
const expanded = normalized[body + offset];
|
|
184
|
+
if (line === undefined || !expanded?.endsWith(text)) return text;
|
|
185
|
+
const skip = expanded.length - text.length;
|
|
186
|
+
let column = 0;
|
|
187
|
+
for (let char = 0; char < line.length; char++) {
|
|
188
|
+
if (column === skip) return line.slice(char);
|
|
189
|
+
column += line[char] === "\t" ? 3 : 1;
|
|
190
|
+
if (column > skip) return " ".repeat(column - skip) + line.slice(char + 1);
|
|
191
|
+
}
|
|
192
|
+
return "";
|
|
193
|
+
}).join("\n");
|
|
194
|
+
return restored.replace(/\t/g, " ") === rendered ? restored : rendered;
|
|
195
|
+
}
|
|
196
|
+
return rendered;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function patch<T extends object, K extends keyof T>(target: T, key: K, value: T[K]): () => void {
|
|
200
|
+
const had = Object.prototype.hasOwnProperty.call(target, key);
|
|
201
|
+
const original = target[key];
|
|
202
|
+
target[key] = value;
|
|
203
|
+
return () => {
|
|
204
|
+
// Another extension may have wrapped ours; leave its wrapper in place then.
|
|
205
|
+
if (target[key] !== value) return;
|
|
206
|
+
if (had) target[key] = original;
|
|
207
|
+
else Reflect.deleteProperty(target, key);
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function installCopyButtons(options: {
|
|
212
|
+
getTui: () => TUI | undefined;
|
|
213
|
+
getTheme: () => Theme;
|
|
214
|
+
isEnabled: () => boolean;
|
|
215
|
+
}): { cleanup: () => void; refresh: () => void } {
|
|
216
|
+
const markdownProto = Markdown.prototype as unknown as MarkdownInternals & Record<symbol, unknown>;
|
|
217
|
+
const userProto = UserMessageComponent.prototype as unknown as UserMessageInternals;
|
|
218
|
+
const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
|
|
219
|
+
|
|
220
|
+
const refresh = () => {
|
|
221
|
+
const tui = options.getTui();
|
|
222
|
+
tui?.invalidate();
|
|
223
|
+
tui?.requestRender();
|
|
224
|
+
};
|
|
225
|
+
if (markdownProto[PATCH_KEY] || markdownProto[PRETTY_TUI_KEY]) return { cleanup: () => {}, refresh };
|
|
226
|
+
|
|
227
|
+
if (typeof altScreenProto.handleMouseEvent !== "function" || typeof altScreenProto.createMouseEvent !== "function") {
|
|
228
|
+
return { cleanup: () => {}, refresh };
|
|
229
|
+
}
|
|
230
|
+
let installed = true;
|
|
231
|
+
const active = () => installed && options.isEnabled() && options.getTui()?.mode === "fullscreen";
|
|
232
|
+
const copy = (text: string) => {
|
|
233
|
+
const tui = options.getTui();
|
|
234
|
+
const flash = (message: string) => {
|
|
235
|
+
if (tui instanceof TuiAltScreen) tui.flash(message);
|
|
236
|
+
};
|
|
237
|
+
copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
|
|
238
|
+
};
|
|
239
|
+
const label = () => options.getTheme().fg("muted", LABEL);
|
|
240
|
+
|
|
241
|
+
const originalRender = markdownProto.render;
|
|
242
|
+
const originalRenderToken = markdownProto.renderToken;
|
|
243
|
+
const originalMarkdownMouse = markdownProto.handleMouse;
|
|
244
|
+
const originalUserRender = userProto.render;
|
|
245
|
+
const originalUserMouse = userProto.handleMouse;
|
|
246
|
+
const originalAltScreenMouse = altScreenProto.handleMouseEvent;
|
|
247
|
+
|
|
248
|
+
const restores = [
|
|
249
|
+
patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
|
|
250
|
+
if (!active() || this !== (options.getTui() as unknown)) return originalAltScreenMouse.call(this, raw);
|
|
251
|
+
const motion = (raw.button & 32) !== 0;
|
|
252
|
+
if (!raw.release && !motion) press = undefined;
|
|
253
|
+
// Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
|
|
254
|
+
if (press && (raw.release || motion)) {
|
|
255
|
+
const event = this.createMouseEvent(raw.release ? "release" : "drag", raw.button, raw.x, raw.y);
|
|
256
|
+
if (handleCopyMouse(press.owner, [], event, press.onCopy)) {
|
|
257
|
+
this.resetTextSelection();
|
|
258
|
+
this.requestRender();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
return originalAltScreenMouse.call(this, raw);
|
|
263
|
+
}),
|
|
264
|
+
patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
|
|
265
|
+
const scope = renderScope;
|
|
266
|
+
if (!installed || !scope || token.type !== "code") return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
267
|
+
const ordinal = scope.ordinal++;
|
|
268
|
+
if (!scope.frame || !active() || width < MIN_FRAME_WIDTH) {
|
|
269
|
+
return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
270
|
+
}
|
|
271
|
+
const theme = options.getTheme();
|
|
272
|
+
const showLabel = scope.label && width >= MIN_FRAME_WIDTH + LABEL.length + 2;
|
|
273
|
+
const lines = renderFramedCode(
|
|
274
|
+
this.theme,
|
|
275
|
+
token.text,
|
|
276
|
+
token.lang,
|
|
277
|
+
width,
|
|
278
|
+
showLabel ? label() : undefined,
|
|
279
|
+
(text) => theme.fg("borderMuted", text),
|
|
280
|
+
);
|
|
281
|
+
if (showLabel) scope.blocks.push({ text: token.text, ordinal });
|
|
282
|
+
// Same spacing rule as Pi's own code-block rendering.
|
|
283
|
+
if (nextTokenType && nextTokenType !== "space") lines.push("");
|
|
284
|
+
return lines;
|
|
285
|
+
}),
|
|
286
|
+
patch(markdownProto, "render", function (this: MarkdownInternals, width) {
|
|
287
|
+
if (!installed) return originalRender.call(this, width);
|
|
288
|
+
const outer = renderScope;
|
|
289
|
+
const scope = { frame: false, label: false, ordinal: 0, blocks: [] as { text: string; ordinal: number }[] };
|
|
290
|
+
renderScope = scope;
|
|
291
|
+
let lines: string[];
|
|
292
|
+
try {
|
|
293
|
+
lines = originalRender.call(this, width);
|
|
294
|
+
} finally {
|
|
295
|
+
renderScope = outer;
|
|
296
|
+
}
|
|
297
|
+
// A cache hit returns the array we already processed and skips renderToken.
|
|
298
|
+
if (markdownRegions.get(this)?.lines === lines) return lines;
|
|
299
|
+
const source = typeof this.text === "string" ? this.text : "";
|
|
300
|
+
const regions: CopyRegion[] = [];
|
|
301
|
+
let next = 0;
|
|
302
|
+
for (let row = 0; row < lines.length; row++) {
|
|
303
|
+
const line = lines[row]!;
|
|
304
|
+
const at = line.indexOf(MARKER);
|
|
305
|
+
if (at < 0) continue;
|
|
306
|
+
// The renderer caches this array, so stripping in place keeps the marker out of later frames.
|
|
307
|
+
lines[row] = line.split(MARKER).join("");
|
|
308
|
+
const block = scope.blocks[next++];
|
|
309
|
+
if (!block) continue;
|
|
310
|
+
regions.push({
|
|
311
|
+
row,
|
|
312
|
+
col: visibleWidth(line.slice(0, at)),
|
|
313
|
+
width: LABEL.length,
|
|
314
|
+
text: restoreTabs(source, block.ordinal, block.text),
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
markdownRegions.set(this, { lines, regions });
|
|
318
|
+
return lines;
|
|
319
|
+
}),
|
|
320
|
+
patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
|
|
321
|
+
if (!active()) return originalMarkdownMouse?.call(this, event);
|
|
322
|
+
const regions = markdownRegions.get(this)?.regions ?? [];
|
|
323
|
+
return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
|
|
324
|
+
}),
|
|
325
|
+
patch(userProto, "render", function (this: UserMessageInternals, width) {
|
|
326
|
+
if (!installed) return originalUserRender.call(this, width);
|
|
327
|
+
const lines = originalUserRender.call(this, width);
|
|
328
|
+
userRegions.delete(this);
|
|
329
|
+
const pad = typeof this.outputPad === "number" ? this.outputPad : 1;
|
|
330
|
+
const col = width - pad - LABEL.length;
|
|
331
|
+
if (!active() || lines.length < 2 || col < 1 || typeof this.text !== "string") return lines;
|
|
332
|
+
// The last row is the bubble's bottom padding, so the label never shares a row with a code-block label.
|
|
333
|
+
const row = lines.length - 1;
|
|
334
|
+
const theme = options.getTheme();
|
|
335
|
+
lines[row] = compositeTuiLine(lines[row]!, theme.bg("userMessageBg", label()), col, LABEL.length, width);
|
|
336
|
+
userRegions.set(this, { row, col, width: LABEL.length, text: this.text });
|
|
337
|
+
return lines;
|
|
338
|
+
}),
|
|
339
|
+
patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
|
|
340
|
+
if (!active()) return originalUserMouse?.call(this, event);
|
|
341
|
+
const region = userRegions.get(this);
|
|
342
|
+
return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
|
|
343
|
+
}),
|
|
344
|
+
];
|
|
345
|
+
markdownProto[PATCH_KEY] = true;
|
|
346
|
+
refresh();
|
|
347
|
+
|
|
348
|
+
return {
|
|
349
|
+
cleanup: () => {
|
|
350
|
+
if (!installed) return;
|
|
351
|
+
installed = false;
|
|
352
|
+
for (const restore of restores) restore();
|
|
353
|
+
delete markdownProto[PATCH_KEY];
|
|
354
|
+
press = undefined;
|
|
355
|
+
try {
|
|
356
|
+
refresh();
|
|
357
|
+
} catch {
|
|
358
|
+
// The TUI may already be stopping during shutdown.
|
|
359
|
+
}
|
|
360
|
+
},
|
|
361
|
+
refresh,
|
|
362
|
+
};
|
|
363
|
+
}
|