@pi-archimedes/footer 0.2.0
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/package.json +32 -0
- package/src/config.ts +50 -0
- package/src/cost-accumulator.ts +35 -0
- package/src/index.ts +163 -0
- package/src/utils/format.ts +45 -0
- package/src/utils/git.ts +106 -0
- package/src/utils/icons.ts +37 -0
- package/src/utils/stats.ts +64 -0
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pi-archimedes/footer",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package"
|
|
7
|
+
],
|
|
8
|
+
"description": "Rich footer status bar for pi-archimedes",
|
|
9
|
+
"files": [
|
|
10
|
+
"src"
|
|
11
|
+
],
|
|
12
|
+
"main": "./src/index.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./src/index.ts",
|
|
15
|
+
"./config": "./src/config.ts"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@pi-archimedes/core": "0.2.0"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
22
|
+
"@earendil-works/pi-tui": ">=0.1.0"
|
|
23
|
+
},
|
|
24
|
+
"devDependencies": {
|
|
25
|
+
"typescript": "^6.0.0"
|
|
26
|
+
},
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": [
|
|
29
|
+
"./src/index.ts"
|
|
30
|
+
]
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
5
|
+
|
|
6
|
+
export interface FooterConfig {
|
|
7
|
+
splitThreshold: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const SETTINGS_PATH = join(getAgentDir(), "settings.json");
|
|
11
|
+
|
|
12
|
+
export const DEFAULT_FOOTER_CONFIG: FooterConfig = {
|
|
13
|
+
splitThreshold: 150,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function loadFooterConfig(): FooterConfig {
|
|
17
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
18
|
+
try {
|
|
19
|
+
const full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
20
|
+
return { ...DEFAULT_FOOTER_CONFIG, ...(full["archimedes.footer"] ?? {}) };
|
|
21
|
+
} catch {
|
|
22
|
+
/* ignore corrupt file */
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return DEFAULT_FOOTER_CONFIG;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function saveFooterConfig(config: FooterConfig): void {
|
|
29
|
+
let full: Record<string, unknown> = {};
|
|
30
|
+
if (existsSync(SETTINGS_PATH)) {
|
|
31
|
+
try {
|
|
32
|
+
full = JSON.parse(readFileSync(SETTINGS_PATH, "utf-8"));
|
|
33
|
+
} catch {
|
|
34
|
+
/* ignore corrupt file */
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
full["archimedes.footer"] = config;
|
|
38
|
+
writeFileSync(SETTINGS_PATH, JSON.stringify(full, null, 2), "utf-8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getFooterSettingsItems(): SettingItem[] {
|
|
42
|
+
return [
|
|
43
|
+
{
|
|
44
|
+
id: "splitThreshold",
|
|
45
|
+
label: "Split Threshold",
|
|
46
|
+
description: "Terminal width threshold for two-line footer split",
|
|
47
|
+
currentValue: String(loadFooterConfig().splitThreshold),
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getBus, Events, type CostUpdatePayload } from "@pi-archimedes/core/bus";
|
|
2
|
+
|
|
3
|
+
export class CostAccumulator {
|
|
4
|
+
inputTokens = 0;
|
|
5
|
+
outputTokens = 0;
|
|
6
|
+
cacheReadTokens = 0;
|
|
7
|
+
cacheWriteTokens = 0;
|
|
8
|
+
cost = 0;
|
|
9
|
+
private unsubscribes: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
subscribe(): void {
|
|
12
|
+
const unsub = getBus().on(Events.COST_UPDATE, (payload: unknown) => {
|
|
13
|
+
const data = payload as CostUpdatePayload;
|
|
14
|
+
this.inputTokens += data.inputTokens ?? 0;
|
|
15
|
+
this.outputTokens += data.outputTokens ?? 0;
|
|
16
|
+
this.cacheReadTokens += data.cacheReadTokens ?? 0;
|
|
17
|
+
this.cacheWriteTokens += data.cacheWriteTokens ?? 0;
|
|
18
|
+
this.cost += data.cost ?? 0;
|
|
19
|
+
});
|
|
20
|
+
this.unsubscribes.push(unsub);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
reset(): void {
|
|
24
|
+
this.inputTokens = 0;
|
|
25
|
+
this.outputTokens = 0;
|
|
26
|
+
this.cacheReadTokens = 0;
|
|
27
|
+
this.cacheWriteTokens = 0;
|
|
28
|
+
this.cost = 0;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dispose(): void {
|
|
32
|
+
this.unsubscribes.forEach(unsub => unsub());
|
|
33
|
+
this.unsubscribes = [];
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dir | model | ◐thinking | branch [+status] | worktree | ↑↓R W $cost | ━━━━━ context%
|
|
3
|
+
* Splits into two lines when terminal width < splitThreshold (default 150):
|
|
4
|
+
* Line 1: system info (dir, branch, model, thinking, worktree)
|
|
5
|
+
* Line 2: usage stats (↑↓R W $cost + context progress bar)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
10
|
+
import { clampLine } from "@pi-archimedes/core/text";
|
|
11
|
+
import { loadFooterConfig } from "./config.js";
|
|
12
|
+
import { CostAccumulator } from "./cost-accumulator.js";
|
|
13
|
+
import { getGitStatus, getWorktreeBranch } from "./utils/git.js";
|
|
14
|
+
import { getContextWindowInfo, getTokenUsageStats, type TokenUsageStats } from "./utils/stats.js";
|
|
15
|
+
import { formatContextBar, formatGitStatusIndicators, formatThinkingIndicator, formatTokenCount } from "./utils/format.js";
|
|
16
|
+
import { footerIcons } from "./utils/icons.js";
|
|
17
|
+
|
|
18
|
+
export function registerFooter(pi: ExtensionAPI): void {
|
|
19
|
+
|
|
20
|
+
pi.on("session_start", (_event, ctx: ExtensionContext) => {
|
|
21
|
+
const splitThreshold = loadFooterConfig().splitThreshold;
|
|
22
|
+
|
|
23
|
+
// Create cost accumulator for subagent costs
|
|
24
|
+
const accumulator = new CostAccumulator();
|
|
25
|
+
accumulator.subscribe();
|
|
26
|
+
|
|
27
|
+
ctx.ui.setFooter((tui, theme, footerData) => {
|
|
28
|
+
const unsubscribe = footerData.onBranchChange(() => tui.requestRender());
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
dispose: unsubscribe,
|
|
32
|
+
invalidate() { },
|
|
33
|
+
render(width: number): string[] {
|
|
34
|
+
try {
|
|
35
|
+
const colorize = (token: string, s: string) => theme.fg(token as any, s);
|
|
36
|
+
const activeModel = ctx.model?.id || "no-model";
|
|
37
|
+
const currentBranch = footerData.getGitBranch();
|
|
38
|
+
const currentDirectory = process.cwd().split("/").pop() || process.cwd();
|
|
39
|
+
const gitStatus = getGitStatus();
|
|
40
|
+
const worktreeBranch = getWorktreeBranch();
|
|
41
|
+
const thinkingLevel = pi.getThinkingLevel();
|
|
42
|
+
|
|
43
|
+
// Merge main agent stats with subagent stats from accumulator
|
|
44
|
+
const mainStats = getTokenUsageStats(ctx);
|
|
45
|
+
const mergedStats: TokenUsageStats = {
|
|
46
|
+
totalInput: mainStats.totalInput + accumulator.inputTokens,
|
|
47
|
+
totalOutput: mainStats.totalOutput + accumulator.outputTokens,
|
|
48
|
+
totalCacheRead: mainStats.totalCacheRead + accumulator.cacheReadTokens,
|
|
49
|
+
totalCacheWrite: mainStats.totalCacheWrite + accumulator.cacheWriteTokens,
|
|
50
|
+
totalCost: mainStats.totalCost + accumulator.cost,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost } = mergedStats;
|
|
54
|
+
const { percent: contextPercent, percentValue: contextPercentValue, windowSize: contextWindowSize } = getContextWindowInfo(ctx);
|
|
55
|
+
|
|
56
|
+
// ── Two-line split for narrow terminals ────────────────────────────
|
|
57
|
+
|
|
58
|
+
const shouldSplit = width < splitThreshold;
|
|
59
|
+
|
|
60
|
+
// Thinking display
|
|
61
|
+
const thinkingIndicatorStr = formatThinkingIndicator(thinkingLevel, colorize);
|
|
62
|
+
|
|
63
|
+
// Git status indicators
|
|
64
|
+
const gitStatusStr = formatGitStatusIndicators(gitStatus, colorize);
|
|
65
|
+
|
|
66
|
+
// Left section: dir | branch [+status] | model | thinking | worktree
|
|
67
|
+
const leftSections = [
|
|
68
|
+
colorize("syntaxFunction", " " + footerIcons.directory + currentDirectory),
|
|
69
|
+
currentBranch ? colorize("success", footerIcons.branch + " " + currentBranch + (gitStatusStr ? " " + gitStatusStr : "")) : "",
|
|
70
|
+
colorize("syntaxType", footerIcons.model + " " + activeModel),
|
|
71
|
+
thinkingIndicatorStr,
|
|
72
|
+
worktreeBranch ? colorize("syntaxNumber", footerIcons.worktree + " " + worktreeBranch) : "",
|
|
73
|
+
].filter(Boolean);
|
|
74
|
+
|
|
75
|
+
const separator = theme.fg("dim", " | ");
|
|
76
|
+
const leftSectionStr = leftSections.join(separator);
|
|
77
|
+
|
|
78
|
+
// Token stats with context percentage
|
|
79
|
+
const statsParts: string[] = [];
|
|
80
|
+
if (totalInput) statsParts.push("↑" + formatTokenCount(totalInput));
|
|
81
|
+
if (totalOutput) statsParts.push("↓" + formatTokenCount(totalOutput));
|
|
82
|
+
if (totalCacheRead) statsParts.push("R" + formatTokenCount(totalCacheRead));
|
|
83
|
+
if (totalCacheWrite) statsParts.push("W" + formatTokenCount(totalCacheWrite));
|
|
84
|
+
if (totalCost) statsParts.push("$" + totalCost.toFixed(2));
|
|
85
|
+
|
|
86
|
+
const contextUsed = contextWindowSize * (contextPercentValue / 100);
|
|
87
|
+
const contextDisplay =
|
|
88
|
+
contextPercent === "?"
|
|
89
|
+
? "?"
|
|
90
|
+
: formatTokenCount(contextUsed) + "/" + formatTokenCount(contextWindowSize);
|
|
91
|
+
const contextColored =
|
|
92
|
+
contextPercentValue > 95
|
|
93
|
+
? theme.fg("error", contextDisplay)
|
|
94
|
+
: contextPercentValue > 80
|
|
95
|
+
? theme.fg("warning", contextDisplay)
|
|
96
|
+
: contextDisplay;
|
|
97
|
+
statsParts.push(contextColored);
|
|
98
|
+
|
|
99
|
+
const rawStatsSectionStr = statsParts.join(" ");
|
|
100
|
+
const statsSectionStr = theme.fg("dim", rawStatsSectionStr);
|
|
101
|
+
|
|
102
|
+
if (shouldSplit) {
|
|
103
|
+
// ── Two-line mode ──────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
// Calculate available space for the context progress bar on line 2
|
|
106
|
+
const availableBarSpace = Math.max(2, width - visibleWidth(statsSectionStr) - 13);
|
|
107
|
+
|
|
108
|
+
// Context progress bar (expands to fill remaining space)
|
|
109
|
+
const contextBarStr = formatContextBar(colorize as (token: string, s: string) => string, contextPercentValue, availableBarSpace);
|
|
110
|
+
|
|
111
|
+
// Assemble line 2: stats | bar
|
|
112
|
+
const rightSections: string[] = [];
|
|
113
|
+
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
114
|
+
if (contextBarStr) rightSections.push(contextBarStr);
|
|
115
|
+
const rightSectionStr = rightSections.join(theme.fg("dim", " | "));
|
|
116
|
+
|
|
117
|
+
// Edge case: if both stats and bar are empty, return only line 1
|
|
118
|
+
if (!rightSectionStr) {
|
|
119
|
+
return [clampLine(leftSectionStr, width)];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return [
|
|
123
|
+
clampLine(leftSectionStr, width),
|
|
124
|
+
clampLine(rightSectionStr, width),
|
|
125
|
+
];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ── Single-line mode ───────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
// Separator between left and right sections
|
|
131
|
+
const sectionSeparator = theme.fg("dim", " | ");
|
|
132
|
+
|
|
133
|
+
// Calculate available space for the context progress bar (after stats)
|
|
134
|
+
const availableBarSpace = Math.max(
|
|
135
|
+
2,
|
|
136
|
+
width - visibleWidth(leftSectionStr) - 1 - visibleWidth(sectionSeparator) - visibleWidth(statsSectionStr) - 10,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
// Context progress bar (expands to fill remaining space)
|
|
140
|
+
const contextBarStr = formatContextBar(colorize as (token: string, s: string) => string, contextPercentValue, availableBarSpace);
|
|
141
|
+
|
|
142
|
+
// Assemble: left | stats | bar
|
|
143
|
+
const rightSections: string[] = [];
|
|
144
|
+
if (statsSectionStr) rightSections.push(statsSectionStr);
|
|
145
|
+
if (contextBarStr) rightSections.push(contextBarStr);
|
|
146
|
+
const rightSectionStr = rightSections.join(theme.fg("dim", " | "));
|
|
147
|
+
|
|
148
|
+
return [clampLine(leftSectionStr + sectionSeparator + rightSectionStr, width)];
|
|
149
|
+
} catch (e) {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
pi.on("session_shutdown", (_event, _ctx) => {
|
|
157
|
+
accumulator.dispose();
|
|
158
|
+
accumulator.reset();
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export default registerFooter;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { footerIcons, gitDisplayIcons, gitStatusColors, thinkingLevelColors, type ColorFn } from "./icons.js";
|
|
2
|
+
|
|
3
|
+
export function formatTokenCount(count: number): string {
|
|
4
|
+
const K = 1024;
|
|
5
|
+
const M = 1048576; // 1024 * 1024
|
|
6
|
+
|
|
7
|
+
if (count < K) return count.toString();
|
|
8
|
+
if (count < K * 10) return (count / K).toFixed(1) + "k";
|
|
9
|
+
if (count < M) return Math.round(count / K) + "k";
|
|
10
|
+
if (count < M * 10) return (count / M).toFixed(1) + "M";
|
|
11
|
+
return Math.round(count / M) + "M";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatContextBar(colorize: ColorFn, percentValue: number, availableSpace: number): string {
|
|
15
|
+
if (availableSpace <= 2) return "";
|
|
16
|
+
|
|
17
|
+
const pct = Math.min(1, percentValue / 100);
|
|
18
|
+
const filledLength = percentValue > 0 ? Math.max(1, Math.round(pct * availableSpace)) : 0;
|
|
19
|
+
const emptyLength = availableSpace - filledLength;
|
|
20
|
+
|
|
21
|
+
const barToken = pct >= 0.9 ? "error" : pct >= 0.7 ? "warning" : "syntaxString";
|
|
22
|
+
|
|
23
|
+
const filledBar = filledLength > 0 ? colorize(barToken, "━".repeat(filledLength)) : "";
|
|
24
|
+
const emptyBar = emptyLength > 0 ? colorize("dim", "━".repeat(emptyLength)) : "";
|
|
25
|
+
const bar = filledBar + emptyBar;
|
|
26
|
+
|
|
27
|
+
return colorize(barToken, footerIcons.contextWindow) + " " + bar + " " + colorize(barToken, Math.round(percentValue) + "%");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function formatGitStatusIndicators(
|
|
31
|
+
gitStatus: { staged: number; unstaged: number; untracked: number; ahead: number; behind: number },
|
|
32
|
+
colorize: ColorFn,
|
|
33
|
+
): string {
|
|
34
|
+
const statusParts: string[] = [];
|
|
35
|
+
if (gitStatus.staged > 0) statusParts.push(colorize(gitStatusColors.staged, gitDisplayIcons.staged + gitStatus.staged));
|
|
36
|
+
if (gitStatus.unstaged > 0) statusParts.push(colorize(gitStatusColors.unstaged, gitDisplayIcons.unstaged + gitStatus.unstaged));
|
|
37
|
+
if (gitStatus.untracked > 0) statusParts.push(colorize(gitStatusColors.untracked, gitDisplayIcons.untracked + gitStatus.untracked));
|
|
38
|
+
if (gitStatus.ahead > 0) statusParts.push(colorize(gitStatusColors.ahead, gitDisplayIcons.ahead + gitStatus.ahead));
|
|
39
|
+
if (gitStatus.behind > 0) statusParts.push(colorize(gitStatusColors.behind, gitDisplayIcons.behind + gitStatus.behind));
|
|
40
|
+
return statusParts.join("");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatThinkingIndicator(thinkingLevel: string, colorize: ColorFn): string {
|
|
44
|
+
return thinkingLevel !== "off" ? colorize(thinkingLevelColors[thinkingLevel] || "dim", "◐ " + thinkingLevel) : "";
|
|
45
|
+
}
|
package/src/utils/git.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execSync } from "child_process";
|
|
2
|
+
import { realpathSync } from "fs";
|
|
3
|
+
|
|
4
|
+
export interface GitStatus {
|
|
5
|
+
staged: number;
|
|
6
|
+
unstaged: number;
|
|
7
|
+
untracked: number;
|
|
8
|
+
ahead: number;
|
|
9
|
+
behind: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const STAGED_INDEX_STATES = ["A", "M", "D", "R", "C", "U", "T"] as const;
|
|
13
|
+
const UNSTAGED_WORKTREE_STATES = ["M", "D", "U"] as const;
|
|
14
|
+
|
|
15
|
+
interface FileStates {
|
|
16
|
+
indexField: string;
|
|
17
|
+
workTreeField: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function parseGitStatusLine(line: string): FileStates | null {
|
|
21
|
+
// Scored format: "<score> XY..."
|
|
22
|
+
const scoredMatch = line.match(/^\d+ (..) /);
|
|
23
|
+
if (scoredMatch) return { indexField: scoredMatch[1][0], workTreeField: scoredMatch[1][1] };
|
|
24
|
+
|
|
25
|
+
// Unscored format: "XY..."
|
|
26
|
+
const noScoreMatch = line.match(/^(..) /);
|
|
27
|
+
if (noScoreMatch) return { indexField: noScoreMatch[1][0], workTreeField: noScoreMatch[1][1] };
|
|
28
|
+
|
|
29
|
+
// Untracked format: "? ..."
|
|
30
|
+
const untrackedMatch = line.match(/^(.) (.)/);
|
|
31
|
+
if (!untrackedMatch) return null;
|
|
32
|
+
return { indexField: untrackedMatch[1], workTreeField: untrackedMatch[2] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function getGitStatus(): GitStatus {
|
|
36
|
+
const status: GitStatus = { staged: 0, unstaged: 0, untracked: 0, ahead: 0, behind: 0 };
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const gitOutput = execSync("git status --porcelain=v2 -uall", {
|
|
40
|
+
cwd: process.cwd(),
|
|
41
|
+
encoding: "utf8",
|
|
42
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
for (const line of gitOutput.trim().split("\n")) {
|
|
46
|
+
// Branch summary: "## branch_name ... upstream ahead behind"
|
|
47
|
+
if (/^## /.test(line)) {
|
|
48
|
+
const branchParts = line.slice(3).trim().split(/\s+/);
|
|
49
|
+
if (branchParts.length >= 3) {
|
|
50
|
+
const commitsAhead = Number(branchParts[branchParts.length - 2]);
|
|
51
|
+
const commitsBehind = Number(branchParts[branchParts.length - 1]);
|
|
52
|
+
if (!isNaN(commitsAhead) && !isNaN(commitsBehind) && branchParts[branchParts.length - 3]) {
|
|
53
|
+
status.ahead = Math.max(0, commitsAhead);
|
|
54
|
+
status.behind = Math.max(0, commitsBehind);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const fileStates = parseGitStatusLine(line);
|
|
61
|
+
if (!fileStates) continue;
|
|
62
|
+
|
|
63
|
+
if (STAGED_INDEX_STATES.includes(fileStates.indexField as unknown as typeof STAGED_INDEX_STATES[number])) {
|
|
64
|
+
status.staged++;
|
|
65
|
+
}
|
|
66
|
+
if (fileStates.indexField === "?") {
|
|
67
|
+
status.untracked++;
|
|
68
|
+
} else if (UNSTAGED_WORKTREE_STATES.includes(fileStates.workTreeField as unknown as typeof UNSTAGED_WORKTREE_STATES[number])) {
|
|
69
|
+
status.unstaged++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
} catch {
|
|
73
|
+
/* not a git repo or command failed */
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return status;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getWorktreeBranch(): string | null {
|
|
80
|
+
try {
|
|
81
|
+
const worktreeOutput = execSync("git worktree list --porcelain", {
|
|
82
|
+
cwd: process.cwd(),
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const worktreeEntries = worktreeOutput.trim().split("\n\n").filter(Boolean);
|
|
88
|
+
if (worktreeEntries.length <= 1) return null;
|
|
89
|
+
|
|
90
|
+
const currentDirectoryPath = realpathSync(process.cwd());
|
|
91
|
+
for (const entry of worktreeEntries) {
|
|
92
|
+
const entryLines = entry.split("\n");
|
|
93
|
+
const pathLine = entryLines.find((l) => l.startsWith("worktree "));
|
|
94
|
+
const branchLine = entryLines.find((l) => l.startsWith("branch "));
|
|
95
|
+
const worktreePath = pathLine?.replace("worktree ", "");
|
|
96
|
+
|
|
97
|
+
if (worktreePath && (currentDirectoryPath === worktreePath || currentDirectoryPath.startsWith(worktreePath + "/"))) {
|
|
98
|
+
return branchLine?.replace("branch refs/heads/", "") ?? null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
/* not a git repo */
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Nerd Font icons
|
|
2
|
+
export const footerIcons = {
|
|
3
|
+
model: "\udb81\udea9 ",
|
|
4
|
+
directory: "\uf4d3 ",
|
|
5
|
+
branch: "\uf126",
|
|
6
|
+
worktree: "\u{f0405}",
|
|
7
|
+
contextWindow: "\uee9c",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
// Git status display icons
|
|
11
|
+
export const gitDisplayIcons = {
|
|
12
|
+
staged: "●",
|
|
13
|
+
unstaged: "~",
|
|
14
|
+
untracked: "U",
|
|
15
|
+
ahead: "↑",
|
|
16
|
+
behind: "↓",
|
|
17
|
+
} as const;
|
|
18
|
+
|
|
19
|
+
export const gitStatusColors: Record<keyof typeof gitDisplayIcons, "success" | "warning" | "dim" | "info"> = {
|
|
20
|
+
staged: "success",
|
|
21
|
+
unstaged: "warning",
|
|
22
|
+
untracked: "dim",
|
|
23
|
+
ahead: "info",
|
|
24
|
+
behind: "warning",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const thinkingLevelColors: Record<string, string> = {
|
|
28
|
+
off: "dim",
|
|
29
|
+
minimal: "thinkingMinimal",
|
|
30
|
+
low: "thinkingLow",
|
|
31
|
+
medium: "thinkingMedium",
|
|
32
|
+
high: "thinkingHigh",
|
|
33
|
+
xhigh: "thinkingXhigh",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// Color function type used by formatting functions
|
|
37
|
+
export type ColorFn = (token: string, s: string) => string;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
interface MessageUsage {
|
|
4
|
+
input: number;
|
|
5
|
+
output: number;
|
|
6
|
+
cacheRead: number;
|
|
7
|
+
cacheWrite: number;
|
|
8
|
+
cost: { total: number };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface AssistantMessage {
|
|
12
|
+
usage: MessageUsage;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface TokenUsageStats {
|
|
16
|
+
totalInput: number;
|
|
17
|
+
totalOutput: number;
|
|
18
|
+
totalCacheRead: number;
|
|
19
|
+
totalCacheWrite: number;
|
|
20
|
+
totalCost: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getTokenUsageStats(ctx: ExtensionContext): TokenUsageStats {
|
|
24
|
+
let totalInput = 0,
|
|
25
|
+
totalOutput = 0,
|
|
26
|
+
totalCacheRead = 0,
|
|
27
|
+
totalCacheWrite = 0,
|
|
28
|
+
totalCost = 0;
|
|
29
|
+
|
|
30
|
+
for (const sessionEntry of ctx.sessionManager.getEntries()) {
|
|
31
|
+
if (sessionEntry.type === "message" && sessionEntry.message.role === "assistant") {
|
|
32
|
+
const assistantMessage = sessionEntry.message as AssistantMessage;
|
|
33
|
+
totalInput += assistantMessage.usage.input;
|
|
34
|
+
totalOutput += assistantMessage.usage.output;
|
|
35
|
+
totalCacheRead += assistantMessage.usage.cacheRead;
|
|
36
|
+
totalCacheWrite += assistantMessage.usage.cacheWrite;
|
|
37
|
+
totalCost += assistantMessage.usage.cost.total;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { totalInput, totalOutput, totalCacheRead, totalCacheWrite, totalCost };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface ContextWindowInfo {
|
|
45
|
+
percent: string;
|
|
46
|
+
percentValue: number;
|
|
47
|
+
windowSize: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getContextWindowInfo(ctx: ExtensionContext): ContextWindowInfo {
|
|
51
|
+
const contextUsage = ctx.getContextUsage();
|
|
52
|
+
const modelContextWindow = contextUsage?.contextWindow ?? ctx.model?.contextWindow ?? 0;
|
|
53
|
+
const tokenStats = getTokenUsageStats(ctx);
|
|
54
|
+
|
|
55
|
+
const percentValue =
|
|
56
|
+
contextUsage?.percent ??
|
|
57
|
+
(modelContextWindow > 0 ? ((tokenStats.totalInput + tokenStats.totalOutput) / modelContextWindow) * 100 : 0);
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
percent: contextUsage?.percent != null ? percentValue.toFixed(1) : "?",
|
|
61
|
+
percentValue,
|
|
62
|
+
windowSize: modelContextWindow,
|
|
63
|
+
};
|
|
64
|
+
}
|