pi-open-tui 0.4.2 → 0.4.3
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/CHANGELOG.md +25 -0
- package/README.md +52 -120
- package/README.zh-CN.md +52 -120
- package/docs/configuration.md +245 -0
- package/docs/footer-segments.md +121 -0
- package/docs/implementation.md +185 -0
- package/extensions/open-tui/config.ts +25 -7
- package/extensions/open-tui/copy-buttons.ts +141 -63
- package/extensions/open-tui/footer-segments.ts +119 -0
- package/extensions/open-tui/footer.ts +121 -13
- package/extensions/open-tui/index.ts +8 -1
- package/extensions/open-tui/settings-command.ts +312 -63
- package/extensions/open-tui/tool-preview.ts +8 -2
- package/extensions/open-tui/tool-view.ts +184 -31
- package/extensions/open-tui/utils.ts +18 -6
- package/package.json +9 -8
|
@@ -22,6 +22,7 @@ export interface FooterSegments {
|
|
|
22
22
|
tokens: boolean;
|
|
23
23
|
cost: boolean;
|
|
24
24
|
liveTps: boolean;
|
|
25
|
+
extensionSegments: boolean;
|
|
25
26
|
extensionStatuses: boolean;
|
|
26
27
|
capitalizeProviderName: boolean;
|
|
27
28
|
nerdIconSpacing: boolean;
|
|
@@ -41,6 +42,14 @@ export interface ThinkingPeekConfig {
|
|
|
41
42
|
lines: ThinkingPeekLines;
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
export interface ToolViewConfig {
|
|
46
|
+
mode: ToolViewMode;
|
|
47
|
+
/** In compact mode, also collapse tools that are not Pi's own built-ins. */
|
|
48
|
+
extensionTools: boolean;
|
|
49
|
+
/** Tool names that always keep their native rendering. */
|
|
50
|
+
exclude: string[];
|
|
51
|
+
}
|
|
52
|
+
|
|
44
53
|
export interface OpenTuiConfig {
|
|
45
54
|
enabled: boolean;
|
|
46
55
|
/** Override Pi's header with the open-tui header. When false, the header slot is left to Pi or another extension. */
|
|
@@ -56,9 +65,7 @@ export interface OpenTuiConfig {
|
|
|
56
65
|
footerSegments: FooterSegments;
|
|
57
66
|
telemetry: TelemetryConfig;
|
|
58
67
|
thinkingPeek: ThinkingPeekConfig;
|
|
59
|
-
toolView:
|
|
60
|
-
mode: ToolViewMode;
|
|
61
|
-
};
|
|
68
|
+
toolView: ToolViewConfig;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
@@ -83,6 +90,7 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
83
90
|
tokens: true,
|
|
84
91
|
cost: true,
|
|
85
92
|
liveTps: true,
|
|
93
|
+
extensionSegments: true,
|
|
86
94
|
extensionStatuses: true,
|
|
87
95
|
capitalizeProviderName: true,
|
|
88
96
|
nerdIconSpacing: false,
|
|
@@ -101,6 +109,8 @@ export const DEFAULT_CONFIG: OpenTuiConfig = {
|
|
|
101
109
|
},
|
|
102
110
|
toolView: {
|
|
103
111
|
mode: "native",
|
|
112
|
+
extensionTools: false,
|
|
113
|
+
exclude: [],
|
|
104
114
|
},
|
|
105
115
|
};
|
|
106
116
|
|
|
@@ -113,6 +123,17 @@ function normalizeThinkingPeekLines(value: unknown): ThinkingPeekLines {
|
|
|
113
123
|
return value === 0 || value === 1 || value === 2 ? value : DEFAULT_CONFIG.thinkingPeek.lines;
|
|
114
124
|
}
|
|
115
125
|
|
|
126
|
+
function normalizeToolView(value: unknown): ToolViewConfig {
|
|
127
|
+
const source = typeof value === "object" && value !== null && !Array.isArray(value)
|
|
128
|
+
? (value as Partial<Record<keyof ToolViewConfig, unknown>>)
|
|
129
|
+
: {};
|
|
130
|
+
return {
|
|
131
|
+
mode: source.mode === "compact" ? "compact" : DEFAULT_CONFIG.toolView.mode,
|
|
132
|
+
extensionTools: source.extensionTools === true,
|
|
133
|
+
exclude: Array.isArray(source.exclude) ? source.exclude.filter((name): name is string => typeof name === "string") : [],
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
116
137
|
const ICON_MODES: IconMode[] = ["auto", "nerd", "unicode", "ascii"];
|
|
117
138
|
|
|
118
139
|
// Non-boolean flags (e.g. "false" strings) fall back to defaults instead of reading as truthy.
|
|
@@ -198,10 +219,7 @@ export function loadConfig(notify?: (msg: string, level: "warning" | "info") =>
|
|
|
198
219
|
} else {
|
|
199
220
|
config.thinkingPeek.lines = normalizeThinkingPeekLines(config.thinkingPeek.lines);
|
|
200
221
|
}
|
|
201
|
-
|
|
202
|
-
|| (config.toolView.mode !== "native" && config.toolView.mode !== "compact")) {
|
|
203
|
-
config.toolView = structuredClone(DEFAULT_CONFIG.toolView);
|
|
204
|
-
}
|
|
222
|
+
config.toolView = normalizeToolView(config.toolView);
|
|
205
223
|
return config;
|
|
206
224
|
} catch (err) {
|
|
207
225
|
notify?.(`open-tui config parse error: ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
import { copyToClipboard, UserMessageComponent, type MarkdownTransformContext, type Theme } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import {
|
|
3
4
|
compositeTuiLine,
|
|
@@ -13,15 +14,14 @@ import {
|
|
|
13
14
|
type TuiMouseEvent,
|
|
14
15
|
type TuiMouseEventResult,
|
|
15
16
|
} from "@earendil-works/pi-tui";
|
|
17
|
+
import { stripAnsi } from "./utils.ts";
|
|
16
18
|
|
|
17
19
|
const LABEL = "[Copy]";
|
|
18
20
|
// Below this width a frame plus label would crowd the code, so Pi's plain fences are kept.
|
|
19
21
|
const MIN_FRAME_WIDTH = 8;
|
|
20
22
|
// Zero-width APC sequence: survives wrapping and padding, and marks the label column before we strip it.
|
|
21
|
-
const
|
|
23
|
+
const MARKER_PREFIX = "\x1b_open-tui:cb:";
|
|
22
24
|
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
25
|
// Pi only synthesizes `click` for a zero-movement release, so allow a little jitter.
|
|
26
26
|
const RELEASE_TOLERANCE = 2;
|
|
27
27
|
|
|
@@ -58,6 +58,7 @@ interface AltScreenInternals {
|
|
|
58
58
|
interface MarkdownInternals {
|
|
59
59
|
text?: unknown;
|
|
60
60
|
theme: MarkdownTheme;
|
|
61
|
+
invalidate(): void;
|
|
61
62
|
render(width: number): string[];
|
|
62
63
|
renderToken(token: Token, width: number, nextTokenType?: string, styleContext?: unknown): string[];
|
|
63
64
|
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
@@ -69,26 +70,39 @@ interface UserMessageInternals {
|
|
|
69
70
|
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
|
|
70
71
|
}
|
|
71
72
|
|
|
73
|
+
interface MarkdownState {
|
|
74
|
+
active: boolean;
|
|
75
|
+
policy?: { frame: boolean; label: boolean };
|
|
76
|
+
lines?: readonly string[];
|
|
77
|
+
regions: CopyRegion[];
|
|
78
|
+
descriptors: Map<number, { text: string; label: boolean }>;
|
|
79
|
+
nextId: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
72
82
|
/** Set by our Markdown transformer while a transcript Markdown renders. */
|
|
73
|
-
let renderScope: {
|
|
83
|
+
let renderScope: { owner: MarkdownInternals; state: MarkdownState; active: boolean; frame: boolean; label: boolean } | undefined;
|
|
74
84
|
let press: Press | undefined;
|
|
75
|
-
const
|
|
76
|
-
const userRegions = new WeakMap<object, { region: CopyRegion; source: string; label: string; width: number; line: string }>();
|
|
85
|
+
const regionTargets = new WeakMap<CopyRegion, string>();
|
|
77
86
|
|
|
78
87
|
/**
|
|
79
|
-
*
|
|
88
|
+
* Pi calls the transform before consulting its native block cache, so policy changes can invalidate it in this pass.
|
|
80
89
|
* Thinking blocks keep Pi's fences. Streaming replies get the frame but no label: it would copy unfinished code.
|
|
81
90
|
*/
|
|
82
91
|
export function markCopyScope(markdown: string, context: MarkdownTransformContext): string {
|
|
83
92
|
if (renderScope) {
|
|
84
|
-
renderScope.frame = context.messageType !== "assistant-thinking";
|
|
93
|
+
renderScope.frame = renderScope.active && context.messageType !== "assistant-thinking";
|
|
85
94
|
renderScope.label = renderScope.frame && !context.isStreaming;
|
|
95
|
+
const previous = renderScope.state.policy;
|
|
96
|
+
if (previous && (previous.frame !== renderScope.frame || previous.label !== renderScope.label)) {
|
|
97
|
+
renderScope.owner.invalidate();
|
|
98
|
+
}
|
|
99
|
+
renderScope.state.policy = { frame: renderScope.frame, label: renderScope.label };
|
|
86
100
|
}
|
|
87
101
|
return markdown;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
/**
|
|
91
|
-
* Rounded frame with the language and the label in the top border
|
|
105
|
+
* Rounded frame with the language and the label in the top border:
|
|
92
106
|
* `╭─ ts ───── [Copy] ╮`. It replaces the two fence rows, so the block keeps its height.
|
|
93
107
|
*/
|
|
94
108
|
export function renderFramedCode(
|
|
@@ -98,6 +112,7 @@ export function renderFramedCode(
|
|
|
98
112
|
width: number,
|
|
99
113
|
label: string | undefined,
|
|
100
114
|
border: (text: string) => string = theme.codeBlockBorder,
|
|
115
|
+
marker = "",
|
|
101
116
|
): string[] {
|
|
102
117
|
const inner = width - 4;
|
|
103
118
|
const highlighted = theme.highlightCode?.(code, lang) ?? code.split("\n").map((line) => theme.codeBlock(line));
|
|
@@ -111,8 +126,8 @@ export function renderFramedCode(
|
|
|
111
126
|
const widest = Math.max(0, ...rows.map((row) => visibleWidth(row)));
|
|
112
127
|
const frameWidth = Math.min(width, Math.max(widest + 4, visibleWidth(prefix) + 3 + visibleWidth(suffix) + 1));
|
|
113
128
|
const fill = Math.max(0, frameWidth - visibleWidth(prefix) - visibleWidth(suffix) - 1);
|
|
114
|
-
const top = border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
|
|
115
|
-
+ (label === undefined ? "" : ` ${
|
|
129
|
+
const top = (label === undefined ? marker : "") + border("╭─ ") + theme.codeBlockBorder(`${name} `) + border("─".repeat(fill))
|
|
130
|
+
+ (label === undefined ? "" : ` ${marker}${label} `) + border("╮");
|
|
116
131
|
const body = rows.map((row) => border("│ ") + row + " ".repeat(Math.max(0, frameWidth - 4 - visibleWidth(row))) + border(" │"));
|
|
117
132
|
return [top, ...body, border(`╰${"─".repeat(frameWidth - 2)}╯`)];
|
|
118
133
|
}
|
|
@@ -150,7 +165,8 @@ function cancelStalePress(owner: object, regions: readonly CopyRegion[]): void {
|
|
|
150
165
|
if (press?.owner !== owner) return;
|
|
151
166
|
const previous = press.region;
|
|
152
167
|
if (!regions.some((region) => region.row === previous.row && region.col === previous.col
|
|
153
|
-
&& region.width === previous.width && region.text === previous.text
|
|
168
|
+
&& region.width === previous.width && region.text === previous.text
|
|
169
|
+
&& regionTargets.get(region) === regionTargets.get(previous))) {
|
|
154
170
|
press = undefined;
|
|
155
171
|
}
|
|
156
172
|
}
|
|
@@ -228,21 +244,28 @@ export function installCopyButtons(options: {
|
|
|
228
244
|
const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
|
|
229
245
|
|
|
230
246
|
const refresh = () => {
|
|
247
|
+
press = undefined;
|
|
231
248
|
const tui = options.getTui();
|
|
232
249
|
tui?.invalidate();
|
|
233
250
|
tui?.requestRender();
|
|
234
251
|
};
|
|
235
|
-
if (markdownProto[PATCH_KEY]
|
|
252
|
+
if (markdownProto[PATCH_KEY]) return { cleanup: () => {}, refresh };
|
|
236
253
|
|
|
237
|
-
if (typeof
|
|
254
|
+
if (typeof markdownProto.render !== "function" || typeof markdownProto.renderToken !== "function"
|
|
255
|
+
|| typeof markdownProto.invalidate !== "function" || typeof altScreenProto.handleMouseEvent !== "function"
|
|
256
|
+
|| typeof altScreenProto.createMouseEvent !== "function") {
|
|
238
257
|
return { cleanup: () => {}, refresh };
|
|
239
258
|
}
|
|
240
259
|
let installed = true;
|
|
241
260
|
const active = () => installed && options.isEnabled() && options.getTui()?.mode === "fullscreen";
|
|
261
|
+
let markdownRegions = new WeakMap<object, MarkdownState>();
|
|
262
|
+
let userRegions = new WeakMap<object, { region: CopyRegion; source: string; label: string; width: number; line: string }>();
|
|
263
|
+
const markerPrefix = `${MARKER_PREFIX}${randomUUID()}:`;
|
|
264
|
+
const markerPattern = new RegExp(`${markerPrefix}(\\d+):([01])\x07`, "g");
|
|
242
265
|
const copy = (text: string) => {
|
|
243
266
|
const tui = options.getTui();
|
|
244
267
|
const flash = (message: string) => {
|
|
245
|
-
if (tui instanceof TuiAltScreen) tui.flash(message);
|
|
268
|
+
if (installed && tui === options.getTui() && tui instanceof TuiAltScreen) tui.flash(message);
|
|
246
269
|
};
|
|
247
270
|
copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
|
|
248
271
|
};
|
|
@@ -257,7 +280,10 @@ export function installCopyButtons(options: {
|
|
|
257
280
|
|
|
258
281
|
const restores = [
|
|
259
282
|
patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
|
|
260
|
-
if (!active() || this !== (options.getTui() as unknown))
|
|
283
|
+
if (!active() || this !== (options.getTui() as unknown)) {
|
|
284
|
+
press = undefined;
|
|
285
|
+
return originalAltScreenMouse.call(this, raw);
|
|
286
|
+
}
|
|
261
287
|
const motion = (raw.button & 32) !== 0;
|
|
262
288
|
if (!raw.release && !motion) press = undefined;
|
|
263
289
|
// Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
|
|
@@ -273,63 +299,110 @@ export function installCopyButtons(options: {
|
|
|
273
299
|
}),
|
|
274
300
|
patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
|
|
275
301
|
const scope = renderScope;
|
|
276
|
-
if (!installed || !scope || token.type !== "code")
|
|
277
|
-
const ordinal = scope.ordinal++;
|
|
278
|
-
if (!scope.frame || !active() || width < MIN_FRAME_WIDTH) {
|
|
302
|
+
if (!installed || !scope?.active || scope.owner !== this || token.type !== "code") {
|
|
279
303
|
return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
280
304
|
}
|
|
281
|
-
const theme = options.getTheme();
|
|
282
305
|
const showLabel = scope.label && width >= MIN_FRAME_WIDTH + LABEL.length + 2;
|
|
283
|
-
const
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
showLabel ? label() : undefined,
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
306
|
+
const id = scope.state.nextId++;
|
|
307
|
+
const marker = `${markerPrefix}${id}:${showLabel ? 1 : 0}\x07`;
|
|
308
|
+
let lines: string[];
|
|
309
|
+
if (scope.frame && width >= MIN_FRAME_WIDTH) {
|
|
310
|
+
const theme = options.getTheme();
|
|
311
|
+
lines = renderFramedCode(this.theme, token.text, token.lang, width, showLabel ? label() : undefined,
|
|
312
|
+
(text) => theme.fg("borderMuted", text), marker);
|
|
313
|
+
// Same spacing rule as Pi's own code-block rendering.
|
|
314
|
+
if (nextTokenType && nextTokenType !== "space") lines.push("");
|
|
315
|
+
} else {
|
|
316
|
+
lines = originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
317
|
+
// Unlabeled occurrences still count toward the current source ordinal.
|
|
318
|
+
lines[0] = marker + (lines[0] ?? "");
|
|
319
|
+
}
|
|
320
|
+
scope.state.descriptors.set(id, { text: token.text, label: showLabel });
|
|
294
321
|
return lines;
|
|
295
322
|
}),
|
|
296
323
|
patch(markdownProto, "render", function (this: MarkdownInternals, width) {
|
|
297
324
|
if (!installed) return originalRender.call(this, width);
|
|
298
|
-
const
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
325
|
+
const enabled = active();
|
|
326
|
+
const previous = markdownRegions.get(this);
|
|
327
|
+
const state: MarkdownState = previous ?? { active: enabled, regions: [], descriptors: new Map(), nextId: 0 };
|
|
328
|
+
if (!previous) {
|
|
329
|
+
markdownRegions.set(this, state);
|
|
330
|
+
// A component may already have cached native fences before this installation.
|
|
331
|
+
this.invalidate();
|
|
332
|
+
} else if (state.active !== enabled) {
|
|
333
|
+
this.invalidate();
|
|
334
|
+
cancelStalePress(this, []);
|
|
335
|
+
state.active = enabled;
|
|
336
|
+
state.policy = undefined;
|
|
306
337
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (
|
|
316
|
-
//
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
338
|
+
const run = () => {
|
|
339
|
+
const outer = renderScope;
|
|
340
|
+
renderScope = { owner: this, state, active: enabled, frame: false, label: false };
|
|
341
|
+
try { return originalRender.call(this, width); }
|
|
342
|
+
finally { renderScope = outer; }
|
|
343
|
+
};
|
|
344
|
+
try {
|
|
345
|
+
let lines = run();
|
|
346
|
+
if (state.lines === lines) return lines;
|
|
347
|
+
// A retained native block can outlive its description after a renderer change.
|
|
348
|
+
const missing = lines.some((line) => [...line.matchAll(markerPattern)].some((match) => !state.descriptors.has(Number(match[1]))));
|
|
349
|
+
if (missing) {
|
|
350
|
+
this.invalidate();
|
|
351
|
+
lines = run();
|
|
352
|
+
}
|
|
353
|
+
const source = typeof this.text === "string" ? this.text : "";
|
|
354
|
+
const regions: CopyRegion[] = [];
|
|
355
|
+
const live = new Set<number>();
|
|
356
|
+
let ordinal = 0;
|
|
357
|
+
for (let row = 0; row < lines.length; row++) {
|
|
358
|
+
const line = lines[row]!;
|
|
359
|
+
let clean = "";
|
|
360
|
+
let from = 0;
|
|
361
|
+
for (const match of line.matchAll(markerPattern)) {
|
|
362
|
+
clean += line.slice(from, match.index);
|
|
363
|
+
from = match.index + match[0].length;
|
|
364
|
+
const id = Number(match[1]);
|
|
365
|
+
const block = state.descriptors.get(id);
|
|
366
|
+
const currentOrdinal = ordinal++;
|
|
367
|
+
if (block) live.add(id);
|
|
368
|
+
if (match[2] !== "1") continue;
|
|
369
|
+
const suffix = line.slice(from);
|
|
370
|
+
const labelAt = suffix.indexOf(LABEL);
|
|
371
|
+
if (labelAt < 0 || stripAnsi(suffix.slice(0, labelAt)) !== "") continue;
|
|
372
|
+
if (!block?.label || !enabled) {
|
|
373
|
+
// Preserve styles and width while removing an unresolved extension label.
|
|
374
|
+
clean += suffix.slice(0, labelAt) + " ".repeat(LABEL.length);
|
|
375
|
+
from += labelAt + LABEL.length;
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const region = { row, col: visibleWidth(clean), width: LABEL.length,
|
|
379
|
+
text: restoreTabs(source, currentOrdinal, block.text) };
|
|
380
|
+
regionTargets.set(region, `${currentOrdinal}\0${source}`);
|
|
381
|
+
regions.push(region);
|
|
382
|
+
}
|
|
383
|
+
// Only sanitize the final array; native block lines retain their descriptor IDs.
|
|
384
|
+
lines[row] = clean + line.slice(from);
|
|
385
|
+
}
|
|
386
|
+
for (const id of state.descriptors.keys()) if (!live.has(id)) state.descriptors.delete(id);
|
|
387
|
+
cancelStalePress(this, regions);
|
|
388
|
+
state.lines = lines;
|
|
389
|
+
state.regions = regions;
|
|
390
|
+
return lines;
|
|
391
|
+
} catch (error) {
|
|
392
|
+
cancelStalePress(this, []);
|
|
393
|
+
state.lines = undefined;
|
|
394
|
+
state.regions = [];
|
|
395
|
+
state.descriptors.clear();
|
|
396
|
+
state.policy = undefined;
|
|
397
|
+
this.invalidate();
|
|
398
|
+
throw error;
|
|
326
399
|
}
|
|
327
|
-
cancelStalePress(this, regions);
|
|
328
|
-
markdownRegions.set(this, { lines, regions });
|
|
329
|
-
return lines;
|
|
330
400
|
}),
|
|
331
401
|
patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
|
|
332
|
-
if (!active())
|
|
402
|
+
if (!active()) {
|
|
403
|
+
cancelStalePress(this, []);
|
|
404
|
+
return originalMarkdownMouse?.call(this, event);
|
|
405
|
+
}
|
|
333
406
|
const regions = markdownRegions.get(this)?.regions ?? [];
|
|
334
407
|
return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
|
|
335
408
|
}),
|
|
@@ -365,7 +438,10 @@ export function installCopyButtons(options: {
|
|
|
365
438
|
return lines;
|
|
366
439
|
}),
|
|
367
440
|
patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
|
|
368
|
-
if (!active())
|
|
441
|
+
if (!active()) {
|
|
442
|
+
cancelStalePress(this, []);
|
|
443
|
+
return originalUserMouse?.call(this, event);
|
|
444
|
+
}
|
|
369
445
|
const region = userRegions.get(this)?.region;
|
|
370
446
|
return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
|
|
371
447
|
}),
|
|
@@ -380,6 +456,8 @@ export function installCopyButtons(options: {
|
|
|
380
456
|
for (const restore of restores) restore();
|
|
381
457
|
delete markdownProto[PATCH_KEY];
|
|
382
458
|
press = undefined;
|
|
459
|
+
markdownRegions = new WeakMap();
|
|
460
|
+
userRegions = new WeakMap();
|
|
383
461
|
try {
|
|
384
462
|
refresh();
|
|
385
463
|
} catch {
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { sanitizeStatusPreservingStyles } from "./utils.ts";
|
|
4
|
+
|
|
5
|
+
const BUILTIN_SEGMENTS = [
|
|
6
|
+
"cwd", "hostname", "sessionName", "git", "runtime", "timer", "context", "model", "tokens", "cost",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
export type FooterBuiltinSegment = typeof BUILTIN_SEGMENTS[number];
|
|
10
|
+
|
|
11
|
+
export type FooterPosition =
|
|
12
|
+
| { row: 1 | 2; side: "left" | "right"; edge?: "start" | "end" }
|
|
13
|
+
| { before: FooterBuiltinSegment }
|
|
14
|
+
| { after: FooterBuiltinSegment };
|
|
15
|
+
|
|
16
|
+
export interface FooterSegment {
|
|
17
|
+
key: string;
|
|
18
|
+
position: FooterPosition;
|
|
19
|
+
text: string;
|
|
20
|
+
compactText?: string;
|
|
21
|
+
order?: number;
|
|
22
|
+
priority?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RegisteredFooterSegment extends FooterSegment {
|
|
26
|
+
order: number;
|
|
27
|
+
priority: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type FooterSetMessage = FooterSegment & { lifecycle: string };
|
|
31
|
+
export type FooterRemoveMessage = { key: string; lifecycle: string };
|
|
32
|
+
export type FooterStateMessage = { available: true; lifecycle: string } | { available: false };
|
|
33
|
+
|
|
34
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
35
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAnchor(value: unknown): value is FooterBuiltinSegment {
|
|
39
|
+
return typeof value === "string" && BUILTIN_SEGMENTS.some((anchor) => anchor === value);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readPosition(value: unknown): FooterPosition | undefined {
|
|
43
|
+
if (!isRecord(value)) return undefined;
|
|
44
|
+
if ("before" in value) {
|
|
45
|
+
if ("after" in value || "row" in value || "side" in value || "edge" in value) return undefined;
|
|
46
|
+
return isAnchor(value.before) ? { before: value.before } : undefined;
|
|
47
|
+
}
|
|
48
|
+
if ("after" in value) {
|
|
49
|
+
if ("row" in value || "side" in value || "edge" in value) return undefined;
|
|
50
|
+
return isAnchor(value.after) ? { after: value.after } : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (value.row !== 1 && value.row !== 2) return undefined;
|
|
53
|
+
if (value.side !== "left" && value.side !== "right") return undefined;
|
|
54
|
+
if (value.edge !== undefined && value.edge !== "start" && value.edge !== "end") return undefined;
|
|
55
|
+
return { row: value.row, side: value.side, edge: value.edge ?? "end" };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readSegment(value: Record<string, unknown>): RegisteredFooterSegment | undefined {
|
|
59
|
+
if (typeof value.key !== "string" || value.key.trim().length === 0 || typeof value.text !== "string") return undefined;
|
|
60
|
+
if (value.compactText !== undefined && typeof value.compactText !== "string") return undefined;
|
|
61
|
+
if (value.order !== undefined && (typeof value.order !== "number" || !Number.isFinite(value.order))) return undefined;
|
|
62
|
+
if (value.priority !== undefined && (typeof value.priority !== "number" || !Number.isFinite(value.priority))) return undefined;
|
|
63
|
+
const position = readPosition(value.position);
|
|
64
|
+
if (!position) return undefined;
|
|
65
|
+
return {
|
|
66
|
+
key: value.key,
|
|
67
|
+
position,
|
|
68
|
+
text: sanitizeStatusPreservingStyles(value.text),
|
|
69
|
+
compactText: value.compactText === undefined ? undefined : sanitizeStatusPreservingStyles(value.compactText),
|
|
70
|
+
order: value.order ?? 0,
|
|
71
|
+
priority: value.priority ?? -1,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Session-local storage for the public footer protocol. Pi owns subscription cleanup. */
|
|
76
|
+
export function createFooterSegments(events: ExtensionAPI["events"], requestRender: () => void) {
|
|
77
|
+
let lifecycle: string | undefined;
|
|
78
|
+
const segments = new Map<string, RegisteredFooterSegment>();
|
|
79
|
+
let ordered: readonly RegisteredFooterSegment[] = [];
|
|
80
|
+
const announce = () => {
|
|
81
|
+
const state: FooterStateMessage = lifecycle === undefined
|
|
82
|
+
? { available: false }
|
|
83
|
+
: { available: true, lifecycle };
|
|
84
|
+
events.emit("open-tui:footer:state", state);
|
|
85
|
+
};
|
|
86
|
+
const changed = () => {
|
|
87
|
+
ordered = [...segments.values()].sort((a, b) => a.order - b.order || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
88
|
+
requestRender();
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
events.on("open-tui:footer:query", announce);
|
|
92
|
+
events.on("open-tui:footer:set", (data) => {
|
|
93
|
+
if (!lifecycle || !isRecord(data) || data.lifecycle !== lifecycle) return;
|
|
94
|
+
const segment = readSegment(data);
|
|
95
|
+
if (!segment) return;
|
|
96
|
+
segments.set(segment.key, segment);
|
|
97
|
+
changed();
|
|
98
|
+
});
|
|
99
|
+
events.on("open-tui:footer:remove", (data) => {
|
|
100
|
+
if (!lifecycle || !isRecord(data) || data.lifecycle !== lifecycle || typeof data.key !== "string") return;
|
|
101
|
+
if (segments.delete(data.key)) changed();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
getSegments: (): readonly RegisteredFooterSegment[] => ordered,
|
|
106
|
+
activate(): void {
|
|
107
|
+
if (lifecycle !== undefined) return;
|
|
108
|
+
lifecycle = randomUUID();
|
|
109
|
+
announce();
|
|
110
|
+
},
|
|
111
|
+
deactivate(): void {
|
|
112
|
+
if (lifecycle === undefined) return;
|
|
113
|
+
lifecycle = undefined;
|
|
114
|
+
segments.clear();
|
|
115
|
+
ordered = [];
|
|
116
|
+
announce();
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|