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
|
@@ -2,6 +2,7 @@ import type { ExtensionContext, Theme, ThemeColor } from "@earendil-works/pi-cod
|
|
|
2
2
|
import { hostname as osHostname } from "node:os";
|
|
3
3
|
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
4
|
import type { OpenTuiConfig } from "./config.ts";
|
|
5
|
+
import type { FooterBuiltinSegment, FooterPosition, RegisteredFooterSegment } from "./footer-segments.ts";
|
|
5
6
|
import type { IconGlyphs } from "./icons.ts";
|
|
6
7
|
import { resolveGlyphs, resolveIconMode, runtimeSymbol } from "./icons.ts";
|
|
7
8
|
import type { GitStatus } from "./git.ts";
|
|
@@ -168,12 +169,12 @@ function renderContextBar(
|
|
|
168
169
|
return `${contextIcon} ${renderBar(theme, contextPct, barWidth, resolveIconMode(iconMode) === "ascii")} ${pctText} ${theme.fg("dim", "·")} ${ctxText}`;
|
|
169
170
|
}
|
|
170
171
|
|
|
171
|
-
function
|
|
172
|
+
function renderStatsSegments(
|
|
172
173
|
theme: Theme,
|
|
173
174
|
totals: UsageTotals,
|
|
174
175
|
glyphs: IconGlyphs,
|
|
175
176
|
segments: OpenTuiConfig["footerSegments"],
|
|
176
|
-
): string {
|
|
177
|
+
): { tokens: string; cost: string } {
|
|
177
178
|
const stats: string[] = [];
|
|
178
179
|
if (segments.tokens) {
|
|
179
180
|
stats.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(totals.input, totals.cacheRead)}`));
|
|
@@ -185,11 +186,10 @@ function renderStatsBlock(
|
|
|
185
186
|
stats.push(theme.fg(cacheHitColor(totals.latestCacheHitRate), `${glyphs.cacheHit} ${totals.latestCacheHitRate.toFixed(1)}%`));
|
|
186
187
|
}
|
|
187
188
|
}
|
|
188
|
-
|
|
189
|
-
stats.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return stats.join(` ${theme.fg("dim", "|")} `);
|
|
189
|
+
return {
|
|
190
|
+
tokens: stats.join(` ${theme.fg("dim", "|")} `),
|
|
191
|
+
cost: segments.cost ? theme.fg("warning", `${glyphs.cost} $${totals.cost.toFixed(3)}`) : "",
|
|
192
|
+
};
|
|
193
193
|
}
|
|
194
194
|
|
|
195
195
|
const ANSI_RESET = "\x1b[0m";
|
|
@@ -226,6 +226,7 @@ export interface FooterHooks {
|
|
|
226
226
|
setRequestRender: (fn: (() => void) | undefined) => void;
|
|
227
227
|
scheduleGitRefresh: () => void;
|
|
228
228
|
getLiveTps?: () => LiveTps | null;
|
|
229
|
+
getExtensionSegments?: () => readonly RegisteredFooterSegment[];
|
|
229
230
|
canEmbedInEditor?: () => boolean;
|
|
230
231
|
}
|
|
231
232
|
|
|
@@ -278,6 +279,84 @@ function fitInlineSegments(
|
|
|
278
279
|
).filter(Boolean).join(separator);
|
|
279
280
|
}
|
|
280
281
|
|
|
282
|
+
type FooterSide = "left" | "right";
|
|
283
|
+
type FooterLayoutMode = "plain" | "inline";
|
|
284
|
+
|
|
285
|
+
// Empty built-ins retain their slots so before/after contributions remain visible.
|
|
286
|
+
const FOOTER_ANCHORS: Record<FooterLayoutMode, Record<1 | 2, Record<FooterSide, readonly FooterBuiltinSegment[]>>> = {
|
|
287
|
+
plain: {
|
|
288
|
+
1: { left: ["cwd", "hostname", "sessionName", "git", "runtime", "timer"], right: ["context"] },
|
|
289
|
+
2: { left: ["model"], right: ["tokens", "cost"] },
|
|
290
|
+
},
|
|
291
|
+
inline: {
|
|
292
|
+
1: { left: ["sessionName", "git"], right: ["cwd", "hostname", "runtime", "timer", "context"] },
|
|
293
|
+
2: { left: ["model"], right: ["tokens", "cost"] },
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
function fitContributedRow(
|
|
298
|
+
row: 1 | 2,
|
|
299
|
+
mode: FooterLayoutMode,
|
|
300
|
+
builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>>,
|
|
301
|
+
contributions: readonly RegisteredFooterSegment[],
|
|
302
|
+
width: number,
|
|
303
|
+
theme: Theme,
|
|
304
|
+
): InlineFooterLine | undefined {
|
|
305
|
+
let contributed = false;
|
|
306
|
+
const collect = (side: FooterSide): PrioritizedSegment[] => {
|
|
307
|
+
const parts: PrioritizedSegment[] = [];
|
|
308
|
+
const append = (matches: (position: FooterPosition) => boolean) => {
|
|
309
|
+
for (const segment of contributions) {
|
|
310
|
+
if (!matches(segment.position)) continue;
|
|
311
|
+
contributed = true;
|
|
312
|
+
parts.push({
|
|
313
|
+
text: renderStatusText(theme, segment.text),
|
|
314
|
+
compactText: segment.compactText && visibleWidth(segment.compactText) > 0
|
|
315
|
+
? renderStatusText(theme, segment.compactText)
|
|
316
|
+
: undefined,
|
|
317
|
+
priority: segment.priority,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
const atEdge = (edge: "start" | "end") => append((position) => "row" in position
|
|
322
|
+
&& position.row === row && position.side === side && (position.edge ?? "end") === edge);
|
|
323
|
+
atEdge("start");
|
|
324
|
+
for (const anchor of FOOTER_ANCHORS[mode][row][side]) {
|
|
325
|
+
append((position) => "before" in position && position.before === anchor);
|
|
326
|
+
const builtin = builtins[anchor];
|
|
327
|
+
if (builtin?.text) parts.push(builtin);
|
|
328
|
+
append((position) => "after" in position && position.after === anchor);
|
|
329
|
+
}
|
|
330
|
+
atEdge("end");
|
|
331
|
+
return parts;
|
|
332
|
+
};
|
|
333
|
+
const left = collect("left");
|
|
334
|
+
const right = collect("right");
|
|
335
|
+
if (!contributed) return undefined;
|
|
336
|
+
|
|
337
|
+
const leftSeparator = row === 1 && mode === "plain" ? " " : theme.fg("dim", " · ");
|
|
338
|
+
const rightSeparator = row === 1 ? leftSeparator : theme.fg("dim", " | ");
|
|
339
|
+
const fitted = fitSegmentsByPriority([...left, ...right], width, theme.fg("dim", "..."), {
|
|
340
|
+
compactOrder: "priority",
|
|
341
|
+
gapWidth: (previous, next) => {
|
|
342
|
+
if (previous < left.length && next >= left.length) return 1;
|
|
343
|
+
return visibleWidth(next < left.length ? leftSeparator : rightSeparator);
|
|
344
|
+
},
|
|
345
|
+
});
|
|
346
|
+
// Truncation can remove an input's trailing reset; terminate each surviving style.
|
|
347
|
+
const join = (parts: string[], separator: string) => parts.filter(Boolean)
|
|
348
|
+
.map((part) => `${part}${ANSI_RESET}`).join(separator);
|
|
349
|
+
return {
|
|
350
|
+
left: join(fitted.slice(0, left.length), leftSeparator),
|
|
351
|
+
right: join(fitted.slice(left.length), rightSeparator),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function alignContributedRow(line: InlineFooterLine, width: number, theme: Theme): string {
|
|
356
|
+
if (!line.right) return truncateToWidth(line.left, width, theme.fg("dim", "..."));
|
|
357
|
+
return alignRight(line.left, line.right, width, theme);
|
|
358
|
+
}
|
|
359
|
+
|
|
281
360
|
function renderFooterContent(
|
|
282
361
|
ctx: ExtensionContext,
|
|
283
362
|
getState: () => FooterState,
|
|
@@ -287,6 +366,7 @@ function renderFooterContent(
|
|
|
287
366
|
footerData: FooterDataLike,
|
|
288
367
|
width: number,
|
|
289
368
|
getLiveTps: () => LiveTps | null,
|
|
369
|
+
getExtensionSegments: () => readonly RegisteredFooterSegment[],
|
|
290
370
|
): { mainLines: [string, string]; inlineLines: InlineFooterLines; extensionLines: string[] } {
|
|
291
371
|
if (width <= 0) {
|
|
292
372
|
return {
|
|
@@ -310,6 +390,10 @@ function renderFooterContent(
|
|
|
310
390
|
}
|
|
311
391
|
const meta = getModelMeta();
|
|
312
392
|
const totals = getUsageTotals(ctx);
|
|
393
|
+
const builtins: Partial<Record<FooterBuiltinSegment, PrioritizedSegment>> = {};
|
|
394
|
+
const contributions = segments.extensionSegments
|
|
395
|
+
? getExtensionSegments().filter((segment) => visibleWidth(segment.text) > 0)
|
|
396
|
+
: [];
|
|
313
397
|
|
|
314
398
|
const leftParts: PrioritizedSegment[] = [];
|
|
315
399
|
const inlineTopLeftParts: PrioritizedSegment[] = [];
|
|
@@ -332,6 +416,7 @@ function renderFooterContent(
|
|
|
332
416
|
},
|
|
333
417
|
});
|
|
334
418
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
419
|
+
builtins.cwd = leftParts.at(-1)!;
|
|
335
420
|
}
|
|
336
421
|
if (segments.hostname) {
|
|
337
422
|
const shortHost = shortHostname(osHostname());
|
|
@@ -341,6 +426,7 @@ function renderFooterContent(
|
|
|
341
426
|
priority: 3,
|
|
342
427
|
});
|
|
343
428
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
429
|
+
builtins.hostname = leftParts.at(-1)!;
|
|
344
430
|
}
|
|
345
431
|
}
|
|
346
432
|
const sessionName = ctx.sessionManager.getSessionName();
|
|
@@ -352,18 +438,21 @@ function renderFooterContent(
|
|
|
352
438
|
if (segments.sessionName) {
|
|
353
439
|
leftParts.push(sessionPart);
|
|
354
440
|
inlineTopLeftParts.push(sessionPart);
|
|
441
|
+
builtins.sessionName = sessionPart;
|
|
355
442
|
}
|
|
356
443
|
}
|
|
357
444
|
const gitSeg = renderGitSegment(theme, state.git, glyphs, segments);
|
|
358
445
|
if (gitSeg) {
|
|
359
446
|
leftParts.push({ text: gitSeg, priority: 1 });
|
|
360
447
|
inlineTopLeftParts.push(leftParts.at(-1)!);
|
|
448
|
+
builtins.git = leftParts.at(-1)!;
|
|
361
449
|
}
|
|
362
450
|
if (segments.runtime) {
|
|
363
451
|
const runtimeSeg = renderRuntimeSegment(theme, state.runtime, config.icons.mode, segments.nerdIconSpacing);
|
|
364
452
|
if (runtimeSeg) {
|
|
365
453
|
leftParts.push({ text: runtimeSeg, priority: 0 });
|
|
366
454
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
455
|
+
builtins.runtime = leftParts.at(-1)!;
|
|
367
456
|
}
|
|
368
457
|
}
|
|
369
458
|
const timerSeg = renderTimerSegment(theme, state, glyphs);
|
|
@@ -377,6 +466,7 @@ function renderFooterContent(
|
|
|
377
466
|
// Narrow widths shed the live TPS before the timer itself.
|
|
378
467
|
leftParts.push({ text, compactText: liveTps === null ? undefined : timerSeg, priority: 3 });
|
|
379
468
|
inlineTopRightParts.push(leftParts.at(-1)!);
|
|
469
|
+
builtins.timer = leftParts.at(-1)!;
|
|
380
470
|
}
|
|
381
471
|
|
|
382
472
|
// The context bar competes with the left segments for the same row:
|
|
@@ -396,16 +486,20 @@ function renderFooterContent(
|
|
|
396
486
|
allParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
|
|
397
487
|
// Context stays at the far right; cwd is the first item in this group.
|
|
398
488
|
inlineTopRightParts.push({ text: contextText, compactText: contextCompact, priority: 0 });
|
|
489
|
+
builtins.context = allParts.at(-1)!;
|
|
399
490
|
}
|
|
400
491
|
|
|
401
492
|
const fitted = fitSegmentsByPriority(allParts, width, theme.fg("dim", "..."));
|
|
402
493
|
const fittedContext = contextText ? fitted.pop() ?? "" : "";
|
|
403
|
-
const
|
|
494
|
+
const contributedTop = fitContributedRow(1, "plain", builtins, contributions, width, theme);
|
|
495
|
+
const line1 = contributedTop
|
|
496
|
+
? alignContributedRow(contributedTop, width, theme)
|
|
497
|
+
: alignRight(fitted.filter(Boolean).join(" "), fittedContext, width, theme);
|
|
404
498
|
|
|
405
499
|
const inlineLeftBudget = Math.floor(width * 0.45);
|
|
406
500
|
const inlineTopLeft = fitInlineSegments(inlineTopLeftParts, inlineLeftBudget, theme);
|
|
407
501
|
const inlineRightBudget = Math.max(0, width - visibleWidth(inlineTopLeft) - (inlineTopLeft ? 1 : 0));
|
|
408
|
-
const inlineTop: InlineFooterLine = {
|
|
502
|
+
const inlineTop: InlineFooterLine = fitContributedRow(1, "inline", builtins, contributions, width, theme) ?? {
|
|
409
503
|
left: inlineTopLeft,
|
|
410
504
|
right: fitInlineSegments(inlineTopRightParts, inlineRightBudget, theme),
|
|
411
505
|
};
|
|
@@ -420,9 +514,16 @@ function renderFooterContent(
|
|
|
420
514
|
modelParts.push(theme.fg(effortColor(meta.effort), `${glyphs.thinking} ${meta.effort}`));
|
|
421
515
|
}
|
|
422
516
|
const modelBlock = modelParts.join(theme.fg("dim", " · "));
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
|
|
517
|
+
const stats = renderStatsSegments(theme, totals, glyphs, segments);
|
|
518
|
+
const statsBlock = [stats.tokens, stats.cost].filter(Boolean).join(` ${theme.fg("dim", "|")} `);
|
|
519
|
+
builtins.model = { text: modelBlock, priority: 0 };
|
|
520
|
+
builtins.tokens = { text: stats.tokens, priority: 2 };
|
|
521
|
+
builtins.cost = { text: stats.cost, priority: 1 };
|
|
522
|
+
const contributedBottom = fitContributedRow(2, "plain", builtins, contributions, width, theme);
|
|
523
|
+
const inlineBottom = contributedBottom ?? fitInlineLine(modelBlock, statsBlock, width, theme);
|
|
524
|
+
const line2 = contributedBottom
|
|
525
|
+
? alignContributedRow(contributedBottom, width, theme)
|
|
526
|
+
: alignRight(inlineBottom.left, inlineBottom.right, width, theme);
|
|
426
527
|
const mainLines: [string, string] = [line1, line2]
|
|
427
528
|
.map((line) => truncateToWidth(line, width, theme.fg("dim", "..."))) as [string, string];
|
|
428
529
|
const extensionLines = segments.extensionStatuses
|
|
@@ -439,8 +540,11 @@ export function installFooter(
|
|
|
439
540
|
hooks: FooterHooks,
|
|
440
541
|
): FooterHandle {
|
|
441
542
|
let renderInline: (width: number) => InlineFooterLines | undefined = () => undefined;
|
|
543
|
+
let mounted = false;
|
|
442
544
|
|
|
443
545
|
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
546
|
+
mounted = true;
|
|
547
|
+
let disposed = false;
|
|
444
548
|
hooks.setRequestRender(() => tui.requestRender());
|
|
445
549
|
const unsubBranch = footerData.onBranchChange(() => {
|
|
446
550
|
hooks.scheduleGitRefresh();
|
|
@@ -455,12 +559,16 @@ export function installFooter(
|
|
|
455
559
|
footerData,
|
|
456
560
|
width,
|
|
457
561
|
hooks.getLiveTps ?? (() => null),
|
|
562
|
+
hooks.getExtensionSegments ?? (() => []),
|
|
458
563
|
);
|
|
459
564
|
|
|
460
565
|
renderInline = (width) => getContent(width).inlineLines;
|
|
461
566
|
|
|
462
567
|
return {
|
|
463
568
|
dispose() {
|
|
569
|
+
if (disposed) return;
|
|
570
|
+
disposed = true;
|
|
571
|
+
mounted = false;
|
|
464
572
|
unsubBranch();
|
|
465
573
|
hooks.setRequestRender(undefined);
|
|
466
574
|
renderInline = () => undefined;
|
|
@@ -478,7 +586,7 @@ export function installFooter(
|
|
|
478
586
|
return {
|
|
479
587
|
renderInline: (width) => renderInline(width),
|
|
480
588
|
cleanup() {
|
|
481
|
-
ctx.ui.setFooter(undefined);
|
|
589
|
+
if (mounted) ctx.ui.setFooter(undefined);
|
|
482
590
|
},
|
|
483
591
|
};
|
|
484
592
|
}
|
|
@@ -3,6 +3,7 @@ import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, sav
|
|
|
3
3
|
import { installCopyButtons, markCopyScope } from "./copy-buttons.ts";
|
|
4
4
|
import { installEditor } from "./editor.ts";
|
|
5
5
|
import { installFooter } from "./footer.ts";
|
|
6
|
+
import { createFooterSegments } from "./footer-segments.ts";
|
|
6
7
|
import { installHeader } from "./header.ts";
|
|
7
8
|
import { emptyGitStatus, readGitStatus } from "./git.ts";
|
|
8
9
|
import { readRuntimeInfo } from "./runtime.ts";
|
|
@@ -52,6 +53,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
52
53
|
let editor: ReturnType<typeof installEditor> | undefined;
|
|
53
54
|
let copyButtons: ReturnType<typeof installCopyButtons> | undefined;
|
|
54
55
|
let pendingUiChange: PendingUiChange | undefined;
|
|
56
|
+
const footerSegments = createFooterSegments(pi.events, () => requestFooterRender?.());
|
|
55
57
|
|
|
56
58
|
// thinking-peek state (rendered by Pi inside its hidden-thinking block)
|
|
57
59
|
const peek: PeekState = createPeekState();
|
|
@@ -114,11 +116,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
114
116
|
{
|
|
115
117
|
setRequestRender: (fn) => {
|
|
116
118
|
requestFooterRender = fn ?? undefined;
|
|
119
|
+
if (!fn) footerSegments.deactivate();
|
|
120
|
+
else if (active && sessionLifecycle.isCurrent()) footerSegments.activate();
|
|
117
121
|
},
|
|
118
122
|
scheduleGitRefresh: () => {
|
|
119
123
|
void scheduleGitRefresh(ctx);
|
|
120
124
|
},
|
|
121
125
|
getLiveTps: () => turnTelemetry.getLiveTps(),
|
|
126
|
+
getExtensionSegments: footerSegments.getSegments,
|
|
122
127
|
canEmbedInEditor: () => editor?.isFramed() ?? false,
|
|
123
128
|
},
|
|
124
129
|
);
|
|
@@ -138,6 +143,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
138
143
|
isEnabled: () => config.copyButtons,
|
|
139
144
|
});
|
|
140
145
|
active = true;
|
|
146
|
+
if (requestFooterRender) footerSegments.activate();
|
|
141
147
|
}
|
|
142
148
|
};
|
|
143
149
|
|
|
@@ -237,6 +243,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
237
243
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
238
244
|
sessionLifecycle.shutdown();
|
|
239
245
|
stopWorkingTimer();
|
|
246
|
+
footerSegments.deactivate();
|
|
240
247
|
if (active) {
|
|
241
248
|
uninstallUi(ctx);
|
|
242
249
|
}
|
|
@@ -373,7 +380,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
373
380
|
config = loadConfig();
|
|
374
381
|
configLoaded = true;
|
|
375
382
|
}
|
|
376
|
-
return config.enabled ? config.toolView.mode
|
|
383
|
+
return config.enabled ? config.toolView : { ...config.toolView, mode: "native" };
|
|
377
384
|
},
|
|
378
385
|
() => resolveGlyphs(config.icons.mode),
|
|
379
386
|
);
|