@herbertgao/pi-extensions 2026.8.3 → 2026.8.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/README.md +41 -0
- package/examples/pi-footer.json +275 -0
- package/node_modules/pi-footer/CHANGELOG.md +145 -0
- package/node_modules/pi-footer/LICENSE +21 -0
- package/node_modules/pi-footer/README.md +354 -0
- package/node_modules/pi-footer/package.json +68 -0
- package/node_modules/pi-footer/src/cache.ts +106 -0
- package/node_modules/pi-footer/src/colors.ts +237 -0
- package/node_modules/pi-footer/src/config.ts +202 -0
- package/node_modules/pi-footer/src/event-widgets.ts +41 -0
- package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
- package/node_modules/pi-footer/src/git.ts +135 -0
- package/node_modules/pi-footer/src/index.ts +268 -0
- package/node_modules/pi-footer/src/metrics.ts +141 -0
- package/node_modules/pi-footer/src/presets.ts +383 -0
- package/node_modules/pi-footer/src/render.ts +114 -0
- package/node_modules/pi-footer/src/separators.ts +52 -0
- package/node_modules/pi-footer/src/types.ts +122 -0
- package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
- package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
- package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
- package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
- package/node_modules/pi-footer/src/ui/events.ts +35 -0
- package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
- package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
- package/node_modules/pi-footer/src/ui/fields.ts +252 -0
- package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
- package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
- package/node_modules/pi-footer/src/ui/layout.ts +17 -0
- package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
- package/node_modules/pi-footer/src/ui/model.ts +64 -0
- package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
- package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
- package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
- package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
- package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
- package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
- package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
- package/node_modules/pi-footer/src/ui/screen.ts +243 -0
- package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
- package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
- package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
- package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
- package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
- package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
- package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
- package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
- package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
- package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
- package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
- package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
- package/node_modules/pi-footer/src/ui/theme.ts +48 -0
- package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
- package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
- package/node_modules/pi-footer/src/ui.ts +66 -0
- package/node_modules/pi-footer/src/widgets/context.ts +28 -0
- package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
- package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
- package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
- package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
- package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
- package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
- package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
- package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
- package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
- package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
- package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
- package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
- package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
- package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
- package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
- package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
- package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
- package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
- package/node_modules/pi-footer/src/widgets/options.ts +122 -0
- package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
- package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
- package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
- package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
- package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
- package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
- package/node_modules/pi-footer/src/widgets/store.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
- package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
- package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
- package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
- package/node_modules/pi-footer/src/widgets/types.ts +181 -0
- package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
- package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
- package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
- package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
- package/package.json +7 -2
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { basename } from "node:path";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import { asyncCache, CACHE_NAMESPACES } from "./cache.js";
|
|
6
|
+
import type { GitInfo, StatuslineConfig } from "./types.js";
|
|
7
|
+
|
|
8
|
+
const CACHE_TTL_MS = 2000;
|
|
9
|
+
|
|
10
|
+
export const EMPTY_GIT_INFO: GitInfo = {
|
|
11
|
+
branch: null,
|
|
12
|
+
sha: null,
|
|
13
|
+
root: null,
|
|
14
|
+
staged: 0,
|
|
15
|
+
unstaged: 0,
|
|
16
|
+
untracked: 0,
|
|
17
|
+
insertions: 0,
|
|
18
|
+
deletions: 0,
|
|
19
|
+
ahead: 0,
|
|
20
|
+
behind: 0,
|
|
21
|
+
remote: null,
|
|
22
|
+
isRepo: false,
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
interface GitFetch {
|
|
26
|
+
pi: ExtensionAPI;
|
|
27
|
+
cwd: string;
|
|
28
|
+
branchHint: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// SWR read for the live footer: returns the cached (possibly stale) value immediately, kicks an
|
|
32
|
+
// async refresh through asyncCache, and repaints via requestRender when fresh data lands. Stays
|
|
33
|
+
// off the render path's critical section — the git subprocesses run async via pi.exec.
|
|
34
|
+
export function getGitInfo(
|
|
35
|
+
pi: ExtensionAPI,
|
|
36
|
+
cwd: string,
|
|
37
|
+
branchHint: string | null,
|
|
38
|
+
requestRender: () => void,
|
|
39
|
+
): GitInfo {
|
|
40
|
+
return (
|
|
41
|
+
asyncCache.get(
|
|
42
|
+
CACHE_NAMESPACES.git,
|
|
43
|
+
`${cwd}:${branchHint ?? ""}`,
|
|
44
|
+
CACHE_TTL_MS,
|
|
45
|
+
{ pi, cwd, branchHint },
|
|
46
|
+
fetchGitInfo,
|
|
47
|
+
requestRender,
|
|
48
|
+
) ?? EMPTY_GIT_INFO
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Awaitable git fetch for callers that can block (e.g. the config-UI preview command). Same work
|
|
53
|
+
// the SWR fetcher does, exposed directly so a one-shot snapshot can await real data.
|
|
54
|
+
export function loadGitInfo(
|
|
55
|
+
pi: ExtensionAPI,
|
|
56
|
+
cwd: string,
|
|
57
|
+
branchHint: string | null,
|
|
58
|
+
): Promise<GitInfo> {
|
|
59
|
+
return fetchGitInfo({ pi, cwd, branchHint }).then((info) => info ?? EMPTY_GIT_INFO);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function fetchGitInfo({ pi, cwd, branchHint }: GitFetch): Promise<GitInfo | null> {
|
|
63
|
+
const rootPath = await git(pi, cwd, ["rev-parse", "--show-toplevel"]);
|
|
64
|
+
if (!rootPath) return EMPTY_GIT_INFO;
|
|
65
|
+
|
|
66
|
+
const [branch, sha, porcelain, shortstat, aheadBehind, remote] = await Promise.all([
|
|
67
|
+
branchHint ? Promise.resolve(branchHint) : git(pi, cwd, ["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
68
|
+
git(pi, cwd, ["rev-parse", "--short", "HEAD"]),
|
|
69
|
+
git(pi, cwd, ["status", "--porcelain=v1"]),
|
|
70
|
+
git(pi, cwd, ["diff", "--shortstat", "HEAD"]),
|
|
71
|
+
git(pi, cwd, ["rev-list", "--left-right", "--count", "@{upstream}...HEAD"]),
|
|
72
|
+
git(pi, cwd, ["remote", "get-url", "origin"]),
|
|
73
|
+
]);
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
branch,
|
|
77
|
+
sha,
|
|
78
|
+
root: basename(rootPath),
|
|
79
|
+
...parsePorcelain(porcelain),
|
|
80
|
+
...parseShortstat(shortstat),
|
|
81
|
+
...parseAheadBehind(aheadBehind),
|
|
82
|
+
remote,
|
|
83
|
+
isRepo: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function git(pi: ExtensionAPI, cwd: string, args: string[]): Promise<string | null> {
|
|
88
|
+
const { stdout, code, killed } = await pi.exec("git", args, { cwd, timeout: 500 });
|
|
89
|
+
// trimEnd, not trim: `status --porcelain=v1` encodes staged vs unstaged in columns 1/2, so the
|
|
90
|
+
// leading space of the first line is significant. trim() would strip it and miscount that file.
|
|
91
|
+
return code !== 0 || killed ? null : stdout.trimEnd() || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parsePorcelain(output: string | null): Pick<GitInfo, "staged" | "unstaged" | "untracked"> {
|
|
95
|
+
let staged = 0;
|
|
96
|
+
let unstaged = 0;
|
|
97
|
+
let untracked = 0;
|
|
98
|
+
|
|
99
|
+
for (const line of output?.split("\n") ?? []) {
|
|
100
|
+
if (line.length < 2) continue;
|
|
101
|
+
const x = line[0];
|
|
102
|
+
const y = line[1];
|
|
103
|
+
if (x === "?" && y === "?") {
|
|
104
|
+
untracked += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (x !== " " && x !== undefined) staged += 1;
|
|
108
|
+
if (y !== " " && y !== undefined) unstaged += 1;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { staged, unstaged, untracked };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function parseShortstat(output: string | null): Pick<GitInfo, "insertions" | "deletions"> {
|
|
115
|
+
const insertions = /([0-9]+) insertion/.exec(output ?? "")?.[1];
|
|
116
|
+
const deletions = /([0-9]+) deletion/.exec(output ?? "")?.[1];
|
|
117
|
+
return {
|
|
118
|
+
insertions: insertions ? Number(insertions) : 0,
|
|
119
|
+
deletions: deletions ? Number(deletions) : 0,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function parseAheadBehind(output: string | null): Pick<GitInfo, "ahead" | "behind"> {
|
|
124
|
+
const [behind, ahead] = output?.split(/\s+/).map(Number) ?? [];
|
|
125
|
+
return {
|
|
126
|
+
ahead: Number.isFinite(ahead) ? (ahead ?? 0) : 0,
|
|
127
|
+
behind: Number.isFinite(behind) ? (behind ?? 0) : 0,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function hasEnabledGitWidgets(config: Pick<StatuslineConfig, "lines">): boolean {
|
|
132
|
+
return config.lines.some((line) =>
|
|
133
|
+
line.some((widget) => widget.enabled && widget.type.startsWith("git-")),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
cloneConfig,
|
|
10
|
+
configWithPreset,
|
|
11
|
+
DEFAULT_CONFIG,
|
|
12
|
+
isPreset,
|
|
13
|
+
loadConfig,
|
|
14
|
+
saveConfig,
|
|
15
|
+
STATUS_KEY,
|
|
16
|
+
} from "./config.js";
|
|
17
|
+
import { EventWidgetValues, UPDATE_EVENT_WIDGET_EVENT } from "./event-widgets.js";
|
|
18
|
+
import {
|
|
19
|
+
EMPTY_EXTENSION_STATUSES,
|
|
20
|
+
visibleExtensionStatusRowEntries,
|
|
21
|
+
} from "./extension-statuses.js";
|
|
22
|
+
import { EMPTY_GIT_INFO, getGitInfo, hasEnabledGitWidgets, loadGitInfo } from "./git.js";
|
|
23
|
+
import { collectSessionMetrics, collectTurnMetrics } from "./metrics.js";
|
|
24
|
+
import { renderStatuslines } from "./render.js";
|
|
25
|
+
import { isRecord, type GitInfo, type StatuslineConfig, type StatuslineData } from "./types.js";
|
|
26
|
+
import { openStatuslineConfigUi } from "./ui.js";
|
|
27
|
+
import { WidgetStore } from "./widgets/store.js";
|
|
28
|
+
|
|
29
|
+
// Structural mirror of pi's footerData — collectStatuslineData only needs the branch getter.
|
|
30
|
+
interface FooterDataLike {
|
|
31
|
+
getGitBranch(): string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export default async function statuslineExtension(pi: ExtensionAPI): Promise<void> {
|
|
35
|
+
let config = await loadConfig();
|
|
36
|
+
let widgetStore = WidgetStore.fromConfig(config);
|
|
37
|
+
const eventWidgets = new EventWidgetValues();
|
|
38
|
+
let liveTextVerbosity: string | undefined;
|
|
39
|
+
let renderCurrentFooter: (() => void) | undefined;
|
|
40
|
+
let getExtensionStatuses = (): ReadonlyMap<string, string> => EMPTY_EXTENSION_STATUSES;
|
|
41
|
+
|
|
42
|
+
function apply(ctx: ExtensionContext | ExtensionCommandContext): void {
|
|
43
|
+
if (!ctx.hasUI || !config.enabled) {
|
|
44
|
+
ctx.ui.setFooter(undefined);
|
|
45
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("accent", "pi-footer"));
|
|
50
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
51
|
+
getExtensionStatuses = () => footerData.getExtensionStatuses();
|
|
52
|
+
renderCurrentFooter = () => tui.requestRender();
|
|
53
|
+
const unsubscribeBranch = footerData.onBranchChange(() => tui.requestRender());
|
|
54
|
+
return {
|
|
55
|
+
dispose(): void {
|
|
56
|
+
unsubscribeBranch();
|
|
57
|
+
if (renderCurrentFooter) renderCurrentFooter = undefined;
|
|
58
|
+
getExtensionStatuses = () => EMPTY_EXTENSION_STATUSES;
|
|
59
|
+
},
|
|
60
|
+
invalidate(): void {},
|
|
61
|
+
render(width: number): string[] {
|
|
62
|
+
const data = collectStatuslineData(ctx, pi, footerData, eventWidgets.values, {
|
|
63
|
+
config,
|
|
64
|
+
requestRender: () => tui.requestRender(),
|
|
65
|
+
textVerbosity: liveTextVerbosity,
|
|
66
|
+
});
|
|
67
|
+
const lines = renderStatuslines(widgetStore, data, width, {
|
|
68
|
+
getExtensionStatuses,
|
|
69
|
+
theme,
|
|
70
|
+
requestRender: () => tui.requestRender(),
|
|
71
|
+
});
|
|
72
|
+
if (lines.length === 0) return [];
|
|
73
|
+
|
|
74
|
+
const statuses = visibleExtensionStatusRowEntries(
|
|
75
|
+
footerData.getExtensionStatuses(),
|
|
76
|
+
config.extensionStatusRow.hiddenKeys,
|
|
77
|
+
STATUS_KEY,
|
|
78
|
+
).map((entry) => entry.value);
|
|
79
|
+
const renderedLines = lines.map((line) => truncateToWidth(line, width, "…"));
|
|
80
|
+
if (statuses.length === 0) return renderedLines;
|
|
81
|
+
return [
|
|
82
|
+
...renderedLines,
|
|
83
|
+
truncateToWidth(theme.fg("dim", statuses.join(" ")), width, "…"),
|
|
84
|
+
];
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function replaceConfig(next: StatuslineConfig): void {
|
|
91
|
+
config = next;
|
|
92
|
+
widgetStore = WidgetStore.fromConfig(config);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function setConfig(
|
|
96
|
+
next: StatuslineConfig,
|
|
97
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
98
|
+
): Promise<void> {
|
|
99
|
+
replaceConfig(next);
|
|
100
|
+
await saveConfig(config);
|
|
101
|
+
apply(ctx);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
pi.events.on(UPDATE_EVENT_WIDGET_EVENT, (payload: unknown) => {
|
|
105
|
+
const changed = eventWidgets.update(payload);
|
|
106
|
+
if (changed) renderCurrentFooter?.();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// pi has no verbosity accessor; capture the real value from the actual provider
|
|
110
|
+
// request payload. Passive — the footer picks it up on its next render rather than
|
|
111
|
+
// forcing one. Only the openai-codex-responses payload carries text.verbosity.
|
|
112
|
+
//
|
|
113
|
+
// Handlers run in extension load order and the payload is chained forward, so we
|
|
114
|
+
// observe it as of our own position in that chain (we return undefined and never
|
|
115
|
+
// mutate it). A custom provider that bakes verbosity into the body is always seen;
|
|
116
|
+
// a later-loaded extension that rewrites text.verbosity in its own
|
|
117
|
+
// before_provider_request handler is the one case we'd miss.
|
|
118
|
+
pi.on("before_provider_request", (event) => {
|
|
119
|
+
const verbosity = readTextVerbosity(event.payload);
|
|
120
|
+
if (verbosity !== undefined) liveTextVerbosity = verbosity;
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pi.registerCommand("footer", {
|
|
124
|
+
description: "Configure the pi statusline/footer",
|
|
125
|
+
handler: async (args, ctx) => {
|
|
126
|
+
const handled = await handleArgs(args, ctx, config, async (next) => setConfig(next, ctx));
|
|
127
|
+
if (handled) return;
|
|
128
|
+
|
|
129
|
+
const previewData = collectStatuslineData(
|
|
130
|
+
ctx,
|
|
131
|
+
pi,
|
|
132
|
+
{
|
|
133
|
+
getGitBranch: () => "main",
|
|
134
|
+
},
|
|
135
|
+
eventWidgets.values,
|
|
136
|
+
{
|
|
137
|
+
collectGit: true,
|
|
138
|
+
git: await loadGitInfo(pi, ctx.cwd, "main"),
|
|
139
|
+
textVerbosity: liveTextVerbosity,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
const result = await openStatuslineConfigUi(
|
|
143
|
+
ctx,
|
|
144
|
+
config,
|
|
145
|
+
previewData,
|
|
146
|
+
(updated) => {
|
|
147
|
+
replaceConfig(updated);
|
|
148
|
+
apply(ctx);
|
|
149
|
+
},
|
|
150
|
+
async (updated) => setConfig(updated, ctx),
|
|
151
|
+
getExtensionStatuses,
|
|
152
|
+
);
|
|
153
|
+
replaceConfig(result.config);
|
|
154
|
+
apply(ctx);
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
159
|
+
replaceConfig(await loadConfig());
|
|
160
|
+
apply(ctx);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
164
|
+
// A new model selection invalidates the captured verbosity; fall back to the
|
|
165
|
+
// provider default until the next request reveals the real value.
|
|
166
|
+
liveTextVerbosity = undefined;
|
|
167
|
+
apply(ctx);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
171
|
+
ctx.ui.setFooter(undefined);
|
|
172
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function collectStatuslineData(
|
|
177
|
+
ctx: ExtensionContext | ExtensionCommandContext,
|
|
178
|
+
pi: ExtensionAPI,
|
|
179
|
+
footerData: FooterDataLike,
|
|
180
|
+
eventWidgets: ReadonlyMap<string, string>,
|
|
181
|
+
options: {
|
|
182
|
+
config?: StatuslineConfig;
|
|
183
|
+
collectGit?: boolean;
|
|
184
|
+
requestRender?: () => void;
|
|
185
|
+
git?: GitInfo;
|
|
186
|
+
textVerbosity?: string | undefined;
|
|
187
|
+
} = {},
|
|
188
|
+
): StatuslineData {
|
|
189
|
+
const contextUsage = ctx.getContextUsage();
|
|
190
|
+
const collectGit =
|
|
191
|
+
options.collectGit ?? (options.config ? hasEnabledGitWidgets(options.config) : true);
|
|
192
|
+
return {
|
|
193
|
+
model: ctx.model?.id,
|
|
194
|
+
provider: ctx.model?.provider,
|
|
195
|
+
sessionName: pi.getSessionName(),
|
|
196
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
197
|
+
thinkingLevel: ctx.model?.reasoning ? pi.getThinkingLevel() : undefined,
|
|
198
|
+
textVerbosity: getTextVerbosity(ctx.model, options.textVerbosity),
|
|
199
|
+
git:
|
|
200
|
+
options.git ??
|
|
201
|
+
(collectGit
|
|
202
|
+
? getGitInfo(pi, ctx.cwd, footerData.getGitBranch(), options.requestRender ?? (() => {}))
|
|
203
|
+
: EMPTY_GIT_INFO),
|
|
204
|
+
cwd: ctx.cwd,
|
|
205
|
+
activeToolCount: pi.getActiveTools().length,
|
|
206
|
+
usingSubscription: ctx.model ? ctx.modelRegistry.isUsingOAuth(ctx.model) : false,
|
|
207
|
+
contextTokens: contextUsage?.tokens ?? undefined,
|
|
208
|
+
contextMaxTokens: contextUsage?.contextWindow,
|
|
209
|
+
metrics: collectSessionMetrics(ctx.sessionManager.getBranch()),
|
|
210
|
+
turnMetrics: collectTurnMetrics(ctx.sessionManager.getEntries()),
|
|
211
|
+
eventWidgets,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function handleArgs(
|
|
216
|
+
args: string,
|
|
217
|
+
ctx: ExtensionCommandContext,
|
|
218
|
+
currentConfig: StatuslineConfig,
|
|
219
|
+
setConfig: (config: StatuslineConfig) => Promise<void>,
|
|
220
|
+
): Promise<boolean> {
|
|
221
|
+
const [command, value] = args.trim().split(/\s+/, 2);
|
|
222
|
+
if (!command) return false;
|
|
223
|
+
|
|
224
|
+
if (command === "on" || command === "enable") {
|
|
225
|
+
await setConfig({ ...currentConfig, enabled: true });
|
|
226
|
+
ctx.ui.notify("pi-footer enabled", "info");
|
|
227
|
+
return true;
|
|
228
|
+
}
|
|
229
|
+
if (command === "off" || command === "disable") {
|
|
230
|
+
await setConfig({ ...currentConfig, enabled: false });
|
|
231
|
+
ctx.ui.notify("pi-footer disabled", "info");
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
if (command === "reset") {
|
|
235
|
+
await setConfig(cloneConfig(DEFAULT_CONFIG));
|
|
236
|
+
ctx.ui.notify("pi-footer reset to defaults", "info");
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
if (command === "preset" && isPreset(value)) {
|
|
240
|
+
await setConfig(configWithPreset(currentConfig, value));
|
|
241
|
+
ctx.ui.notify(`pi-footer preset: ${value}`, "info");
|
|
242
|
+
return true;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
ctx.ui.notify(
|
|
246
|
+
"Usage: /footer [on|off|reset|preset compact|default|powerline|powerline-bright|powerline-blocks|powerline-mono|git-heavy|pi-footer|demo|demo-standard]",
|
|
247
|
+
"warning",
|
|
248
|
+
);
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function getTextVerbosity(
|
|
253
|
+
model: ExtensionContext["model"],
|
|
254
|
+
liveVerbosity: string | undefined,
|
|
255
|
+
): string | undefined {
|
|
256
|
+
// pi exposes text verbosity only through the OpenAI Codex Responses provider.
|
|
257
|
+
if (!model || model.api !== "openai-codex-responses") return undefined;
|
|
258
|
+
// Prefer the value captured from the real request payload; before the first
|
|
259
|
+
// request (or just after a model switch) fall back to the provider default.
|
|
260
|
+
return liveVerbosity ?? "low";
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function readTextVerbosity(payload: unknown): string | undefined {
|
|
264
|
+
if (!isRecord(payload)) return undefined;
|
|
265
|
+
const text = payload.text;
|
|
266
|
+
if (!isRecord(text)) return undefined;
|
|
267
|
+
return typeof text.verbosity === "string" ? text.verbosity : undefined;
|
|
268
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { isRecord, type SessionMetrics, type TurnMetrics } from "./types.js";
|
|
2
|
+
|
|
3
|
+
// Intentionally loose structural projection of the `usage` field on a pi session message entry.
|
|
4
|
+
// Fields stay `unknown` and are validated at runtime (see isRecord usage below) rather than being
|
|
5
|
+
// derived from pi's SDK types — keeps the defensive parsing robust to upstream shape changes.
|
|
6
|
+
interface UsageLike {
|
|
7
|
+
input?: unknown;
|
|
8
|
+
output?: unknown;
|
|
9
|
+
cacheRead?: unknown;
|
|
10
|
+
cacheWrite?: unknown;
|
|
11
|
+
totalTokens?: unknown;
|
|
12
|
+
cost?: {
|
|
13
|
+
total?: unknown;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Intentionally loose structural projection of a pi session message entry (from
|
|
18
|
+
// sessionManager.getBranch()). Same rationale as UsageLike: `unknown` fields, runtime-validated.
|
|
19
|
+
interface MessageLike {
|
|
20
|
+
role?: unknown;
|
|
21
|
+
timestamp?: unknown;
|
|
22
|
+
usage?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function collectSessionMetrics(entries: readonly unknown[]): SessionMetrics {
|
|
26
|
+
const metrics: SessionMetrics = {
|
|
27
|
+
inputTokens: 0,
|
|
28
|
+
outputTokens: 0,
|
|
29
|
+
cacheReadTokens: 0,
|
|
30
|
+
cacheWriteTokens: 0,
|
|
31
|
+
totalTokens: 0,
|
|
32
|
+
costUsd: 0,
|
|
33
|
+
userMessages: 0,
|
|
34
|
+
assistantMessages: 0,
|
|
35
|
+
toolResults: 0,
|
|
36
|
+
firstTimestampMs: undefined,
|
|
37
|
+
lastTimestampMs: undefined,
|
|
38
|
+
compactions: 0,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
const message = getMessage(entry);
|
|
43
|
+
if (!message) continue;
|
|
44
|
+
|
|
45
|
+
const timestampMs = normalizeTimestamp(message.timestamp ?? getEntryTimestamp(entry));
|
|
46
|
+
if (timestampMs !== undefined) {
|
|
47
|
+
metrics.firstTimestampMs =
|
|
48
|
+
metrics.firstTimestampMs === undefined
|
|
49
|
+
? timestampMs
|
|
50
|
+
: Math.min(metrics.firstTimestampMs, timestampMs);
|
|
51
|
+
metrics.lastTimestampMs =
|
|
52
|
+
metrics.lastTimestampMs === undefined
|
|
53
|
+
? timestampMs
|
|
54
|
+
: Math.max(metrics.lastTimestampMs, timestampMs);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (message.role === "user") metrics.userMessages += 1;
|
|
58
|
+
if (message.role === "toolResult") metrics.toolResults += 1;
|
|
59
|
+
if (message.role === "compactionSummary") metrics.compactions += 1;
|
|
60
|
+
if (message.role !== "assistant") continue;
|
|
61
|
+
|
|
62
|
+
metrics.assistantMessages += 1;
|
|
63
|
+
const usage = getUsage(message.usage);
|
|
64
|
+
if (!usage) continue;
|
|
65
|
+
|
|
66
|
+
const input = numberOrZero(usage.input);
|
|
67
|
+
const output = numberOrZero(usage.output);
|
|
68
|
+
const cacheRead = numberOrZero(usage.cacheRead);
|
|
69
|
+
const cacheWrite = numberOrZero(usage.cacheWrite);
|
|
70
|
+
|
|
71
|
+
metrics.inputTokens += input;
|
|
72
|
+
metrics.outputTokens += output;
|
|
73
|
+
metrics.cacheReadTokens += cacheRead;
|
|
74
|
+
metrics.cacheWriteTokens += cacheWrite;
|
|
75
|
+
metrics.totalTokens +=
|
|
76
|
+
numberOrZero(usage.totalTokens) || input + output + cacheRead + cacheWrite;
|
|
77
|
+
metrics.costUsd += numberOrZero(usage.cost?.total);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return metrics;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function collectTurnMetrics(entries: readonly unknown[]): TurnMetrics {
|
|
84
|
+
const metrics: TurnMetrics = {
|
|
85
|
+
inputTokens: 0,
|
|
86
|
+
outputTokens: 0,
|
|
87
|
+
cacheReadTokens: 0,
|
|
88
|
+
cacheWriteTokens: 0,
|
|
89
|
+
totalTokens: 0,
|
|
90
|
+
costUsd: 0,
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
94
|
+
const message = getMessage(entries[index]);
|
|
95
|
+
if (message?.role !== "assistant") continue;
|
|
96
|
+
|
|
97
|
+
const usage = getUsage(message.usage);
|
|
98
|
+
if (!usage) continue;
|
|
99
|
+
|
|
100
|
+
const input = numberOrZero(usage.input);
|
|
101
|
+
const output = numberOrZero(usage.output);
|
|
102
|
+
const cacheRead = numberOrZero(usage.cacheRead);
|
|
103
|
+
const cacheWrite = numberOrZero(usage.cacheWrite);
|
|
104
|
+
|
|
105
|
+
metrics.inputTokens = input;
|
|
106
|
+
metrics.outputTokens = output;
|
|
107
|
+
metrics.cacheReadTokens = cacheRead;
|
|
108
|
+
metrics.cacheWriteTokens = cacheWrite;
|
|
109
|
+
metrics.totalTokens =
|
|
110
|
+
numberOrZero(usage.totalTokens) || input + output + cacheRead + cacheWrite;
|
|
111
|
+
metrics.costUsd = numberOrZero(usage.cost?.total);
|
|
112
|
+
return metrics;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return metrics;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getMessage(entry: unknown): MessageLike | undefined {
|
|
119
|
+
if (!isRecord(entry)) return undefined;
|
|
120
|
+
const message = entry.message;
|
|
121
|
+
return isRecord(message) ? message : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getEntryTimestamp(entry: unknown): unknown {
|
|
125
|
+
return isRecord(entry) ? entry.timestamp : undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getUsage(value: unknown): UsageLike | undefined {
|
|
129
|
+
return isRecord(value) ? (value as UsageLike) : undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function normalizeTimestamp(value: unknown): number | undefined {
|
|
133
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
134
|
+
if (typeof value !== "string") return undefined;
|
|
135
|
+
const parsed = Date.parse(value);
|
|
136
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function numberOrZero(value: unknown): number {
|
|
140
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
141
|
+
}
|