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