pi-open-tui 0.4.1 → 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 +54 -0
- package/README.md +54 -121
- package/README.zh-CN.md +54 -121
- 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 +172 -66
- 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 +206 -38
- package/extensions/open-tui/utils.ts +18 -6
- package/package.json +10 -8
|
@@ -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, CopyRegion>();
|
|
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
|
}
|
|
@@ -145,6 +160,17 @@ export function handleCopyMouse(
|
|
|
145
160
|
return { handled: true, render: false };
|
|
146
161
|
}
|
|
147
162
|
|
|
163
|
+
/** Cancel a held click when rendering moves, removes, or changes its copy target. */
|
|
164
|
+
function cancelStalePress(owner: object, regions: readonly CopyRegion[]): void {
|
|
165
|
+
if (press?.owner !== owner) return;
|
|
166
|
+
const previous = press.region;
|
|
167
|
+
if (!regions.some((region) => region.row === previous.row && region.col === previous.col
|
|
168
|
+
&& region.width === previous.width && region.text === previous.text
|
|
169
|
+
&& regionTargets.get(region) === regionTargets.get(previous))) {
|
|
170
|
+
press = undefined;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
/**
|
|
149
175
|
* Markdown.render normalizes tabs to three spaces before lexing, so token text loses them.
|
|
150
176
|
* Match the normalized tokens back to source lines: lexing raw lists would expand their tabs first.
|
|
@@ -218,21 +244,28 @@ export function installCopyButtons(options: {
|
|
|
218
244
|
const altScreenProto = TuiAltScreen.prototype as unknown as AltScreenInternals;
|
|
219
245
|
|
|
220
246
|
const refresh = () => {
|
|
247
|
+
press = undefined;
|
|
221
248
|
const tui = options.getTui();
|
|
222
249
|
tui?.invalidate();
|
|
223
250
|
tui?.requestRender();
|
|
224
251
|
};
|
|
225
|
-
if (markdownProto[PATCH_KEY]
|
|
252
|
+
if (markdownProto[PATCH_KEY]) return { cleanup: () => {}, refresh };
|
|
226
253
|
|
|
227
|
-
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") {
|
|
228
257
|
return { cleanup: () => {}, refresh };
|
|
229
258
|
}
|
|
230
259
|
let installed = true;
|
|
231
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");
|
|
232
265
|
const copy = (text: string) => {
|
|
233
266
|
const tui = options.getTui();
|
|
234
267
|
const flash = (message: string) => {
|
|
235
|
-
if (tui instanceof TuiAltScreen) tui.flash(message);
|
|
268
|
+
if (installed && tui === options.getTui() && tui instanceof TuiAltScreen) tui.flash(message);
|
|
236
269
|
};
|
|
237
270
|
copyToClipboard(text).then(() => flash("Copied!"), () => flash("Copy failed"));
|
|
238
271
|
};
|
|
@@ -247,7 +280,10 @@ export function installCopyButtons(options: {
|
|
|
247
280
|
|
|
248
281
|
const restores = [
|
|
249
282
|
patch(altScreenProto, "handleMouseEvent", function (this: AltScreenInternals, raw) {
|
|
250
|
-
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
|
+
}
|
|
251
287
|
const motion = (raw.button & 32) !== 0;
|
|
252
288
|
if (!raw.release && !motion) press = undefined;
|
|
253
289
|
// Leave press/drag to Pi's selection; intercept only a qualifying release before copy-on-select runs.
|
|
@@ -263,82 +299,150 @@ export function installCopyButtons(options: {
|
|
|
263
299
|
}),
|
|
264
300
|
patch(markdownProto, "renderToken", function (this: MarkdownInternals, token, width, nextTokenType, styleContext) {
|
|
265
301
|
const scope = renderScope;
|
|
266
|
-
if (!installed || !scope || token.type !== "code")
|
|
267
|
-
const ordinal = scope.ordinal++;
|
|
268
|
-
if (!scope.frame || !active() || width < MIN_FRAME_WIDTH) {
|
|
302
|
+
if (!installed || !scope?.active || scope.owner !== this || token.type !== "code") {
|
|
269
303
|
return originalRenderToken.call(this, token, width, nextTokenType, styleContext);
|
|
270
304
|
}
|
|
271
|
-
const theme = options.getTheme();
|
|
272
305
|
const showLabel = scope.label && width >= MIN_FRAME_WIDTH + LABEL.length + 2;
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
showLabel ? label() : undefined,
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 });
|
|
284
321
|
return lines;
|
|
285
322
|
}),
|
|
286
323
|
patch(markdownProto, "render", function (this: MarkdownInternals, width) {
|
|
287
324
|
if (!installed) return originalRender.call(this, width);
|
|
288
|
-
const
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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;
|
|
296
337
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
if (
|
|
306
|
-
//
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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;
|
|
316
399
|
}
|
|
317
|
-
markdownRegions.set(this, { lines, regions });
|
|
318
|
-
return lines;
|
|
319
400
|
}),
|
|
320
401
|
patch(markdownProto, "handleMouse", function (this: MarkdownInternals, event) {
|
|
321
|
-
if (!active())
|
|
402
|
+
if (!active()) {
|
|
403
|
+
cancelStalePress(this, []);
|
|
404
|
+
return originalMarkdownMouse?.call(this, event);
|
|
405
|
+
}
|
|
322
406
|
const regions = markdownRegions.get(this)?.regions ?? [];
|
|
323
407
|
return handleCopyMouse(this, regions, event, copy) ?? originalMarkdownMouse?.call(this, event);
|
|
324
408
|
}),
|
|
325
409
|
patch(userProto, "render", function (this: UserMessageInternals, width) {
|
|
326
410
|
if (!installed) return originalUserRender.call(this, width);
|
|
327
411
|
const lines = originalUserRender.call(this, width);
|
|
328
|
-
userRegions.delete(this);
|
|
329
412
|
const pad = typeof this.outputPad === "number" ? this.outputPad : 1;
|
|
330
413
|
const col = width - pad - LABEL.length;
|
|
331
|
-
if (!active() || lines.length < 2 || col < 1 || typeof this.text !== "string")
|
|
414
|
+
if (!active() || lines.length < 2 || col < 1 || typeof this.text !== "string") {
|
|
415
|
+
cancelStalePress(this, []);
|
|
416
|
+
userRegions.delete(this);
|
|
417
|
+
return lines;
|
|
418
|
+
}
|
|
332
419
|
// The last row is the bubble's bottom padding, so the label never shares a row with a code-block label.
|
|
333
420
|
const row = lines.length - 1;
|
|
334
421
|
const theme = options.getTheme();
|
|
335
|
-
|
|
336
|
-
|
|
422
|
+
const button = theme.bg("userMessageBg", label());
|
|
423
|
+
const cached = userRegions.get(this);
|
|
424
|
+
// UserMessage.render returns a fresh array, so compare its undecorated bottom row instead.
|
|
425
|
+
if (cached && cached.source === lines[row] && cached.label === button && cached.width === width
|
|
426
|
+
&& cached.region.row === row && cached.region.col === col && cached.region.text === this.text) {
|
|
427
|
+
lines[row] = cached.line;
|
|
428
|
+
return lines;
|
|
429
|
+
}
|
|
430
|
+
const source = lines[row]!;
|
|
431
|
+
lines[row] = compositeTuiLine(source, button, col, LABEL.length, width);
|
|
432
|
+
const region = { row, col, width: LABEL.length, text: this.text };
|
|
433
|
+
cancelStalePress(this, [region]);
|
|
434
|
+
userRegions.set(this, {
|
|
435
|
+
region,
|
|
436
|
+
source, label: button, width, line: lines[row]!,
|
|
437
|
+
});
|
|
337
438
|
return lines;
|
|
338
439
|
}),
|
|
339
440
|
patch(userProto, "handleMouse", function (this: UserMessageInternals, event) {
|
|
340
|
-
if (!active())
|
|
341
|
-
|
|
441
|
+
if (!active()) {
|
|
442
|
+
cancelStalePress(this, []);
|
|
443
|
+
return originalUserMouse?.call(this, event);
|
|
444
|
+
}
|
|
445
|
+
const region = userRegions.get(this)?.region;
|
|
342
446
|
return handleCopyMouse(this, region ? [region] : [], event, copy) ?? originalUserMouse?.call(this, event);
|
|
343
447
|
}),
|
|
344
448
|
];
|
|
@@ -352,6 +456,8 @@ export function installCopyButtons(options: {
|
|
|
352
456
|
for (const restore of restores) restore();
|
|
353
457
|
delete markdownProto[PATCH_KEY];
|
|
354
458
|
press = undefined;
|
|
459
|
+
markdownRegions = new WeakMap();
|
|
460
|
+
userRegions = new WeakMap();
|
|
355
461
|
try {
|
|
356
462
|
refresh();
|
|
357
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
|
+
}
|