rinco-pi-hud 1.0.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/LICENSE +21 -0
- package/NOTICE +18 -0
- package/README.md +204 -0
- package/docs/CONTRIBUTING.md +110 -0
- package/docs/README.zh-CN.md +204 -0
- package/docs/footer.md +139 -0
- package/extensions/hud/commands/settings.ts +765 -0
- package/extensions/hud/config/config-counts.ts +87 -0
- package/extensions/hud/config/config.ts +65 -0
- package/extensions/hud/config/file-io.ts +128 -0
- package/extensions/hud/config/normalize.ts +374 -0
- package/extensions/hud/config/persist.ts +277 -0
- package/extensions/hud/config/schema.ts +303 -0
- package/extensions/hud/footer/format.ts +279 -0
- package/extensions/hud/footer/index.ts +543 -0
- package/extensions/hud/footer/layout.ts +183 -0
- package/extensions/hud/footer/render-labels.ts +305 -0
- package/extensions/hud/index.ts +466 -0
- package/extensions/hud/segments/extension-status.ts +88 -0
- package/extensions/hud/segments/git.ts +384 -0
- package/extensions/hud/segments/mcp-status.ts +25 -0
- package/extensions/hud/segments/package-version.ts +651 -0
- package/extensions/hud/segments/project-state.ts +67 -0
- package/extensions/hud/segments/runtime-definitions.ts +695 -0
- package/extensions/hud/segments/runtime.ts +167 -0
- package/extensions/hud/segments/skill-activity.ts +152 -0
- package/extensions/hud/session/context.ts +19 -0
- package/extensions/hud/session/lifecycle.ts +60 -0
- package/extensions/hud/session/live-context.ts +75 -0
- package/extensions/hud/state/index.ts +67 -0
- package/extensions/hud/state/project-refresh.ts +104 -0
- package/extensions/hud/state/telemetry.ts +261 -0
- package/extensions/hud/telemetry/codex-usage/LICENSE +21 -0
- package/extensions/hud/telemetry/codex-usage/app-server-client.ts +231 -0
- package/extensions/hud/telemetry/codex-usage/format.ts +278 -0
- package/extensions/hud/telemetry/codex-usage/index.ts +462 -0
- package/extensions/hud/telemetry/codex-usage/normalize.ts +264 -0
- package/extensions/hud/telemetry/codex-usage/query.ts +207 -0
- package/extensions/hud/telemetry/codex-usage/safety.ts +27 -0
- package/extensions/hud/telemetry/codex-usage/types.ts +137 -0
- package/extensions/hud/telemetry/format.ts +434 -0
- package/extensions/hud/telemetry/token-switch.ts +114 -0
- package/extensions/hud/ui/icons.ts +239 -0
- package/extensions/hud/ui/style.ts +276 -0
- package/package.json +56 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { runtimeDefinitions, type RuntimeDef } from "./runtime-definitions.js";
|
|
4
|
+
|
|
5
|
+
export type RuntimeMetadata = {
|
|
6
|
+
name: string;
|
|
7
|
+
symbol: string;
|
|
8
|
+
style: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export type RuntimeInfo = Pick<RuntimeMetadata, "name" | "symbol" | "style"> & {
|
|
12
|
+
version?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export type RuntimeReadResult = { kind: "ok"; runtime?: RuntimeInfo } | { kind: "error" };
|
|
16
|
+
|
|
17
|
+
type RuntimeCacheEntry = {
|
|
18
|
+
fingerprint: string;
|
|
19
|
+
runtime: RuntimeInfo | undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const RUNTIME_CACHE_MAX = 32;
|
|
23
|
+
const runtimeInfoCache = new Map<string, RuntimeCacheEntry>();
|
|
24
|
+
|
|
25
|
+
export function clearRuntimeInfoCache(): void {
|
|
26
|
+
runtimeInfoCache.clear();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function topLevelFingerprint(entries: readonly string[]): string {
|
|
30
|
+
return entries.slice().sort().join("\0");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cacheRuntimeInfo(
|
|
34
|
+
cwd: string,
|
|
35
|
+
fingerprint: string,
|
|
36
|
+
runtime: RuntimeInfo | undefined,
|
|
37
|
+
): void {
|
|
38
|
+
// Drop prior fingerprints for the same cwd so stale marker sets do not linger.
|
|
39
|
+
for (const key of runtimeInfoCache.keys()) {
|
|
40
|
+
if (key === cwd || key.startsWith(`${cwd}\0`)) runtimeInfoCache.delete(key);
|
|
41
|
+
}
|
|
42
|
+
const key = `${cwd}\0${fingerprint}`;
|
|
43
|
+
runtimeInfoCache.set(key, { fingerprint, runtime });
|
|
44
|
+
while (runtimeInfoCache.size > RUNTIME_CACHE_MAX) {
|
|
45
|
+
const oldest = runtimeInfoCache.keys().next().value;
|
|
46
|
+
if (oldest === undefined) break;
|
|
47
|
+
runtimeInfoCache.delete(oldest);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type RuntimeEnvironment = Record<string, string | undefined>;
|
|
52
|
+
|
|
53
|
+
export type DetectionSpec = {
|
|
54
|
+
extensions?: readonly string[];
|
|
55
|
+
files?: readonly string[];
|
|
56
|
+
folders?: readonly string[];
|
|
57
|
+
env?: (env: RuntimeEnvironment) => boolean;
|
|
58
|
+
excludedFiles?: readonly string[];
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// --- Detection utilities ---
|
|
62
|
+
|
|
63
|
+
function hasAnyFile(cwd: string, names: readonly string[]): boolean {
|
|
64
|
+
return names.some((name) => existsSync(join(cwd, name)));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function hasAnyFolder(cwd: string, names: readonly string[]): boolean {
|
|
68
|
+
return names.some((name) => {
|
|
69
|
+
try {
|
|
70
|
+
return statSync(join(cwd, name)).isDirectory();
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function entryExtensions(entry: string): string[] {
|
|
78
|
+
const baseName = entry.split(/[\\/]/).pop() ?? entry;
|
|
79
|
+
if (!baseName || baseName.startsWith(".")) return [];
|
|
80
|
+
|
|
81
|
+
const firstDot = baseName.indexOf(".");
|
|
82
|
+
if (firstDot === -1) return [];
|
|
83
|
+
|
|
84
|
+
const extensions = [baseName.slice(firstDot + 1)];
|
|
85
|
+
const lastDot = baseName.lastIndexOf(".");
|
|
86
|
+
if (lastDot !== firstDot) extensions.push(baseName.slice(lastDot + 1));
|
|
87
|
+
return extensions;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function hasAnyExtension(entries: readonly string[], extensions: readonly string[]): boolean {
|
|
91
|
+
const extensionSet = new Set(extensions);
|
|
92
|
+
return entries.some((entry) =>
|
|
93
|
+
entryExtensions(entry).some((extension) => extensionSet.has(extension)),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function matchesDetection(
|
|
98
|
+
cwd: string,
|
|
99
|
+
entries: string[],
|
|
100
|
+
spec: DetectionSpec,
|
|
101
|
+
env: RuntimeEnvironment,
|
|
102
|
+
): boolean {
|
|
103
|
+
if (spec.excludedFiles && hasAnyFile(cwd, spec.excludedFiles)) return false;
|
|
104
|
+
return Boolean(
|
|
105
|
+
(spec.files && hasAnyFile(cwd, spec.files)) ||
|
|
106
|
+
(spec.folders && hasAnyFolder(cwd, spec.folders)) ||
|
|
107
|
+
(spec.extensions && hasAnyExtension(entries, spec.extensions)) ||
|
|
108
|
+
spec.env?.(env),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Definitions are pre-sorted by priority in runtime-definitions.ts.
|
|
113
|
+
const sortedRuntimes = runtimeDefinitions;
|
|
114
|
+
|
|
115
|
+
export const runtimeMetadata: RuntimeMetadata[] = runtimeDefinitions.map(
|
|
116
|
+
({ name, symbol, style }) => ({
|
|
117
|
+
name,
|
|
118
|
+
symbol,
|
|
119
|
+
style,
|
|
120
|
+
}),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
export function detectRuntime(
|
|
124
|
+
cwd: string,
|
|
125
|
+
entries: string[],
|
|
126
|
+
env: RuntimeEnvironment = process.env,
|
|
127
|
+
): RuntimeDef | undefined {
|
|
128
|
+
for (const runtime of sortedRuntimes) {
|
|
129
|
+
if (matchesDetection(cwd, entries, runtime.detect, env)) return runtime;
|
|
130
|
+
}
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function readRuntimeInfo(cwd: string): Promise<RuntimeReadResult> {
|
|
135
|
+
let entries: string[];
|
|
136
|
+
try {
|
|
137
|
+
entries = readdirSync(cwd);
|
|
138
|
+
} catch {
|
|
139
|
+
// readdir failure is treated as a transient error so last-good can be kept.
|
|
140
|
+
return { kind: "error" };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fingerprint = topLevelFingerprint(entries);
|
|
144
|
+
const cacheKey = `${cwd}\0${fingerprint}`;
|
|
145
|
+
const cached = runtimeInfoCache.get(cacheKey);
|
|
146
|
+
if (cached && cached.fingerprint === fingerprint) {
|
|
147
|
+
return { kind: "ok", runtime: cached.runtime };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const runtime = detectRuntime(cwd, entries);
|
|
152
|
+
if (!runtime) {
|
|
153
|
+
cacheRuntimeInfo(cwd, fingerprint, undefined);
|
|
154
|
+
return { kind: "ok", runtime: undefined };
|
|
155
|
+
}
|
|
156
|
+
const info: RuntimeInfo = {
|
|
157
|
+
name: runtime.name,
|
|
158
|
+
symbol: runtime.symbol,
|
|
159
|
+
style: runtime.style,
|
|
160
|
+
version: await runtime.version(cwd),
|
|
161
|
+
};
|
|
162
|
+
cacheRuntimeInfo(cwd, fingerprint, info);
|
|
163
|
+
return { kind: "ok", runtime: info };
|
|
164
|
+
} catch {
|
|
165
|
+
return { kind: "error" };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { isAbsolute, normalize, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
export interface SkillReference {
|
|
4
|
+
name: string;
|
|
5
|
+
filePath: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface SkillCounts {
|
|
9
|
+
total: number;
|
|
10
|
+
active: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type RecordLike = Record<string, unknown>;
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is RecordLike {
|
|
16
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Normalize a skill path to a canonical absolute form for equality checks.
|
|
21
|
+
*
|
|
22
|
+
* Scope note: the result is only ever used as a `Map`/`Set` key to compare
|
|
23
|
+
* "available skill" paths against "activated skill" paths — it is never
|
|
24
|
+
* passed to `fs`/`child_process`. It intentionally does not verify the
|
|
25
|
+
* normalized path stays under `cwd`; if a future change starts using this
|
|
26
|
+
* path for file I/O, add that containment check first.
|
|
27
|
+
*/
|
|
28
|
+
function normalizeSkillPath(path: string, cwd: string): string | undefined {
|
|
29
|
+
const withoutAt = path.startsWith("@") ? path.slice(1) : path;
|
|
30
|
+
if (!withoutAt || withoutAt.includes("\0")) return undefined;
|
|
31
|
+
return normalize(isAbsolute(withoutAt) ? withoutAt : resolve(cwd, withoutAt));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function messageText(message: RecordLike): string {
|
|
35
|
+
const content = message.content;
|
|
36
|
+
if (typeof content === "string") return content;
|
|
37
|
+
if (!Array.isArray(content)) return "";
|
|
38
|
+
return content
|
|
39
|
+
.filter((item): item is RecordLike => isRecord(item) && item.type === "text")
|
|
40
|
+
.map((item) => (typeof item.text === "string" ? item.text : ""))
|
|
41
|
+
.join("\n");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function toolCalls(message: RecordLike): RecordLike[] {
|
|
45
|
+
if (!Array.isArray(message.content)) return [];
|
|
46
|
+
return message.content.filter(
|
|
47
|
+
(item): item is RecordLike => isRecord(item) && item.type === "toolCall",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function readPathFromToolCall(toolCall: RecordLike): string | undefined {
|
|
52
|
+
if (toolCall.name !== "read" || !isRecord(toolCall.arguments)) return undefined;
|
|
53
|
+
const path = toolCall.arguments.path;
|
|
54
|
+
return typeof path === "string" ? path : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const SKILL_BLOCK_PATTERN = /<skill\s+name="[^"\r\n]{1,128}"\s+location="([^"\r\n]{1,4096})">/g;
|
|
58
|
+
|
|
59
|
+
export class SkillActivityTracker {
|
|
60
|
+
private availableByName = new Map<string, string>();
|
|
61
|
+
private activatedPaths = new Set<string>();
|
|
62
|
+
private synced = false;
|
|
63
|
+
|
|
64
|
+
reset(): void {
|
|
65
|
+
this.availableByName.clear();
|
|
66
|
+
this.activatedPaths.clear();
|
|
67
|
+
this.synced = false;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
syncAvailable(skills: readonly SkillReference[], cwd: string): boolean {
|
|
71
|
+
const before = this.counts();
|
|
72
|
+
const next = new Map<string, string>();
|
|
73
|
+
for (const skill of skills) {
|
|
74
|
+
if (!skill.name || next.has(skill.name)) continue;
|
|
75
|
+
const path = normalizeSkillPath(skill.filePath, cwd);
|
|
76
|
+
if (path) next.set(skill.name, path);
|
|
77
|
+
}
|
|
78
|
+
this.availableByName = next;
|
|
79
|
+
this.synced = true;
|
|
80
|
+
return !sameCounts(before, this.counts());
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
activateFromPrompt(prompt: string, cwd: string): boolean {
|
|
84
|
+
const before = this.counts();
|
|
85
|
+
SKILL_BLOCK_PATTERN.lastIndex = 0;
|
|
86
|
+
let match = SKILL_BLOCK_PATTERN.exec(prompt);
|
|
87
|
+
while (match) {
|
|
88
|
+
const path = match[1] ? normalizeSkillPath(match[1], cwd) : undefined;
|
|
89
|
+
if (path) this.activatedPaths.add(path);
|
|
90
|
+
match = SKILL_BLOCK_PATTERN.exec(prompt);
|
|
91
|
+
}
|
|
92
|
+
return !sameCounts(before, this.counts());
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
activateFromRead(path: string, cwd: string): boolean {
|
|
96
|
+
const before = this.counts();
|
|
97
|
+
const normalized = normalizeSkillPath(path, cwd);
|
|
98
|
+
if (normalized) this.activatedPaths.add(normalized);
|
|
99
|
+
return !sameCounts(before, this.counts());
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
restoreFromEntries(entries: readonly unknown[], cwd: string): boolean {
|
|
103
|
+
const before = this.counts();
|
|
104
|
+
this.activatedPaths.clear();
|
|
105
|
+
const pendingReads = new Map<string, string>();
|
|
106
|
+
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!isRecord(entry) || entry.type !== "message" || !isRecord(entry.message)) continue;
|
|
109
|
+
const message = entry.message;
|
|
110
|
+
if (message.role === "user") {
|
|
111
|
+
this.activateFromPrompt(messageText(message), cwd);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (message.role === "assistant") {
|
|
115
|
+
for (const toolCall of toolCalls(message)) {
|
|
116
|
+
const id = typeof toolCall.id === "string" ? toolCall.id : undefined;
|
|
117
|
+
const path = readPathFromToolCall(toolCall);
|
|
118
|
+
if (id && path) pendingReads.set(id, path);
|
|
119
|
+
}
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (message.role !== "toolResult" || message.toolName !== "read" || message.isError === true) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
const id = typeof message.toolCallId === "string" ? message.toolCallId : undefined;
|
|
126
|
+
const path = id ? pendingReads.get(id) : undefined;
|
|
127
|
+
if (path) this.activateFromRead(path, cwd);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return !sameCounts(before, this.counts());
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
counts(): SkillCounts | undefined {
|
|
134
|
+
if (!this.synced) return undefined;
|
|
135
|
+
const activeNames = new Set<string>();
|
|
136
|
+
for (const [name, path] of this.availableByName) {
|
|
137
|
+
if (this.activatedPaths.has(path)) activeNames.add(name);
|
|
138
|
+
}
|
|
139
|
+
return { total: this.availableByName.size, active: activeNames.size };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function sameCounts(left: SkillCounts | undefined, right: SkillCounts | undefined): boolean {
|
|
144
|
+
return left?.total === right?.total && left?.active === right?.active;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function formatSkillCounts(counts: SkillCounts): string {
|
|
148
|
+
if (counts.total <= 0) return "";
|
|
149
|
+
const total = Math.max(0, Math.trunc(counts.total));
|
|
150
|
+
const active = Math.min(total, Math.max(0, Math.trunc(counts.active)));
|
|
151
|
+
return `★ ${active}/${total}`;
|
|
152
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pi builds a fresh ctx object for every extension event, so comparing a ctx by
|
|
5
|
+
* identity against the one captured at `session_start` drops every later update
|
|
6
|
+
* (model switches, tree navigation, async usage refreshes).
|
|
7
|
+
*
|
|
8
|
+
* A ctx belongs to the live session as long as it is not stale: stale contexts
|
|
9
|
+
* throw on property access after a session replacement or reload.
|
|
10
|
+
*/
|
|
11
|
+
export function isLiveExtensionContext(ctx: ExtensionContext | undefined): boolean {
|
|
12
|
+
if (!ctx) return false;
|
|
13
|
+
try {
|
|
14
|
+
void ctx.hasUI;
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
export class SessionLifecycle {
|
|
2
|
+
private generation = 0;
|
|
3
|
+
private active = false;
|
|
4
|
+
private readonly timeouts = new Set<ReturnType<typeof setTimeout>>();
|
|
5
|
+
|
|
6
|
+
start(): number {
|
|
7
|
+
this.cancelTimeouts();
|
|
8
|
+
this.generation += 1;
|
|
9
|
+
this.active = true;
|
|
10
|
+
return this.generation;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
currentGeneration(): number {
|
|
14
|
+
return this.generation;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isCurrent(generation = this.generation): boolean {
|
|
18
|
+
return this.active && generation === this.generation;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
defer(callback: () => void, delayMs = 0): () => void {
|
|
22
|
+
if (!this.active) return () => {};
|
|
23
|
+
const generation = this.generation;
|
|
24
|
+
let canceled = false;
|
|
25
|
+
const timeout = setTimeout(() => {
|
|
26
|
+
this.timeouts.delete(timeout);
|
|
27
|
+
if (!canceled && this.isCurrent(generation)) callback();
|
|
28
|
+
}, delayMs);
|
|
29
|
+
this.timeouts.add(timeout);
|
|
30
|
+
return () => {
|
|
31
|
+
if (canceled) return;
|
|
32
|
+
canceled = true;
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
this.timeouts.delete(timeout);
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
queueMicrotask(callback: () => void): () => void {
|
|
39
|
+
let canceled = !this.active;
|
|
40
|
+
const generation = this.generation;
|
|
41
|
+
queueMicrotask(() => {
|
|
42
|
+
if (!canceled && this.isCurrent(generation)) callback();
|
|
43
|
+
});
|
|
44
|
+
return () => {
|
|
45
|
+
canceled = true;
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
shutdown(): void {
|
|
50
|
+
if (!this.active && this.timeouts.size === 0) return;
|
|
51
|
+
this.active = false;
|
|
52
|
+
this.generation += 1;
|
|
53
|
+
this.cancelTimeouts();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private cancelTimeouts(): void {
|
|
57
|
+
for (const timeout of this.timeouts) clearTimeout(timeout);
|
|
58
|
+
this.timeouts.clear();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { AssistantMessage, Usage } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { SessionLifecycle } from "./lifecycle.js";
|
|
3
|
+
|
|
4
|
+
export type LiveContextOverride = {
|
|
5
|
+
tokens: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function usageComponent(value: unknown): number {
|
|
9
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 0;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function calculateLiveContextTokens(usage: Usage | undefined): number | undefined {
|
|
13
|
+
if (!usage) return undefined;
|
|
14
|
+
const totalTokens = usageComponent(usage.totalTokens);
|
|
15
|
+
if (totalTokens > 0) return totalTokens;
|
|
16
|
+
const calculated =
|
|
17
|
+
usageComponent(usage.input) +
|
|
18
|
+
usageComponent(usage.output) +
|
|
19
|
+
usageComponent(usage.cacheRead) +
|
|
20
|
+
usageComponent(usage.cacheWrite);
|
|
21
|
+
return calculated > 0 ? calculated : undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function liveContextFromMessage(message: unknown): LiveContextOverride | undefined {
|
|
25
|
+
if (!message || typeof message !== "object") return undefined;
|
|
26
|
+
const assistant = message as Partial<AssistantMessage>;
|
|
27
|
+
if (assistant.role !== "assistant") return undefined;
|
|
28
|
+
if (assistant.stopReason === "error" || assistant.stopReason === "aborted") return undefined;
|
|
29
|
+
const tokens = calculateLiveContextTokens(assistant.usage);
|
|
30
|
+
return tokens === undefined ? undefined : { tokens };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class LiveContextController {
|
|
34
|
+
private readonly lifecycle: SessionLifecycle;
|
|
35
|
+
private readonly requestRender: () => void;
|
|
36
|
+
private override: LiveContextOverride | undefined;
|
|
37
|
+
private cancelScheduledRender: (() => void) | undefined;
|
|
38
|
+
private scheduledGeneration: number | undefined;
|
|
39
|
+
|
|
40
|
+
constructor(lifecycle: SessionLifecycle, requestRender: () => void) {
|
|
41
|
+
this.lifecycle = lifecycle;
|
|
42
|
+
this.requestRender = requestRender;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
get(): LiveContextOverride | undefined {
|
|
46
|
+
return this.override;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
update(message: unknown): boolean {
|
|
50
|
+
const next = liveContextFromMessage(message);
|
|
51
|
+
if (!next || !this.lifecycle.isCurrent()) return false;
|
|
52
|
+
this.override = next;
|
|
53
|
+
const generation = this.lifecycle.currentGeneration();
|
|
54
|
+
if (this.cancelScheduledRender && this.scheduledGeneration !== generation) {
|
|
55
|
+
this.cancelScheduledRender = undefined;
|
|
56
|
+
this.scheduledGeneration = undefined;
|
|
57
|
+
}
|
|
58
|
+
if (!this.cancelScheduledRender) {
|
|
59
|
+
this.scheduledGeneration = generation;
|
|
60
|
+
this.cancelScheduledRender = this.lifecycle.defer(() => {
|
|
61
|
+
this.cancelScheduledRender = undefined;
|
|
62
|
+
this.scheduledGeneration = undefined;
|
|
63
|
+
if (this.override) this.requestRender();
|
|
64
|
+
}, 250);
|
|
65
|
+
}
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
clear(): void {
|
|
70
|
+
this.override = undefined;
|
|
71
|
+
this.cancelScheduledRender?.();
|
|
72
|
+
this.cancelScheduledRender = undefined;
|
|
73
|
+
this.scheduledGeneration = undefined;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { ConfigCounts } from "../config/config-counts.js";
|
|
3
|
+
import {
|
|
4
|
+
buildContextLabel,
|
|
5
|
+
buildCostLabel,
|
|
6
|
+
buildTokenLabel,
|
|
7
|
+
formatProviderLabel,
|
|
8
|
+
getUsageTotals,
|
|
9
|
+
type UsageTotals,
|
|
10
|
+
} from "../telemetry/format.js";
|
|
11
|
+
import type { GitStatusSummary } from "../segments/git.js";
|
|
12
|
+
import type { PackageVersionResult } from "../segments/package-version.js";
|
|
13
|
+
import type { RuntimeInfo } from "../segments/runtime.js";
|
|
14
|
+
import type { SkillCounts } from "../segments/skill-activity.js";
|
|
15
|
+
import { createTelemetryState, type TelemetryState } from "./telemetry.js";
|
|
16
|
+
|
|
17
|
+
export type FooterState = GitStatusSummary & {
|
|
18
|
+
modelLabel: string;
|
|
19
|
+
providerLabel: string;
|
|
20
|
+
contextLabel: string;
|
|
21
|
+
tokenLabel: string;
|
|
22
|
+
costLabel: string;
|
|
23
|
+
usageTotals: UsageTotals;
|
|
24
|
+
telemetry: TelemetryState;
|
|
25
|
+
configCounts: ConfigCounts;
|
|
26
|
+
skillCounts?: SkillCounts;
|
|
27
|
+
codexUsageStatus?: string;
|
|
28
|
+
runtime?: RuntimeInfo;
|
|
29
|
+
packageVersion?: PackageVersionResult;
|
|
30
|
+
sessionStartEpoch?: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
34
|
+
return {
|
|
35
|
+
modelLabel: "no-model",
|
|
36
|
+
providerLabel: "Unknown",
|
|
37
|
+
contextLabel: "--",
|
|
38
|
+
tokenLabel: "↑ 0 ↓ 0",
|
|
39
|
+
costLabel: "$ 0.000",
|
|
40
|
+
usageTotals: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 },
|
|
41
|
+
telemetry: createTelemetryState(),
|
|
42
|
+
configCounts: {
|
|
43
|
+
instructionFiles: { agentsMd: 0, claudeMd: 0, total: 0 },
|
|
44
|
+
packages: 0,
|
|
45
|
+
},
|
|
46
|
+
skillCounts: undefined,
|
|
47
|
+
codexUsageStatus: undefined,
|
|
48
|
+
runtime: undefined,
|
|
49
|
+
packageVersion: undefined,
|
|
50
|
+
sessionStartEpoch: Date.now(),
|
|
51
|
+
...gitDefaults,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function syncState(state: FooterState, ctx: ExtensionContext, cacheHitIcon: string): void {
|
|
56
|
+
const totals = getUsageTotals(ctx);
|
|
57
|
+
state.modelLabel = ctx.model?.id ?? "no-model";
|
|
58
|
+
state.providerLabel = formatProviderLabel(ctx.model?.provider);
|
|
59
|
+
state.usageTotals = totals;
|
|
60
|
+
state.telemetry = {
|
|
61
|
+
...state.telemetry,
|
|
62
|
+
modelSupportsReasoning: Boolean(ctx.model?.reasoning),
|
|
63
|
+
};
|
|
64
|
+
state.contextLabel = buildContextLabel(ctx);
|
|
65
|
+
state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
|
|
66
|
+
state.costLabel = buildCostLabel(totals);
|
|
67
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
export type StopProjectRefreshInterval = () => void;
|
|
2
|
+
|
|
3
|
+
export type ScheduleProjectRefreshOptions = {
|
|
4
|
+
force?: boolean;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ProjectRefreshScheduler<T> = {
|
|
8
|
+
schedule: (target: T, options?: ScheduleProjectRefreshOptions) => void;
|
|
9
|
+
stop: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const PROJECT_REFRESH_THROTTLE_MS = 5_000;
|
|
13
|
+
|
|
14
|
+
export function startProjectRefreshInterval(
|
|
15
|
+
intervalMs: number,
|
|
16
|
+
refresh: () => void,
|
|
17
|
+
): StopProjectRefreshInterval {
|
|
18
|
+
if (intervalMs <= 0) return () => {};
|
|
19
|
+
|
|
20
|
+
const timer = setInterval(refresh, intervalMs);
|
|
21
|
+
timer.unref?.();
|
|
22
|
+
|
|
23
|
+
return () => clearInterval(timer);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createProjectRefreshScheduler<T>(
|
|
27
|
+
refresh: (target: T) => Promise<void>,
|
|
28
|
+
afterRefresh: () => void,
|
|
29
|
+
throttleMs = PROJECT_REFRESH_THROTTLE_MS,
|
|
30
|
+
): ProjectRefreshScheduler<T> {
|
|
31
|
+
let refreshInFlight = false;
|
|
32
|
+
let refreshPending = false;
|
|
33
|
+
let pendingTarget: T | undefined;
|
|
34
|
+
let delayedRefresh: ReturnType<typeof setTimeout> | undefined;
|
|
35
|
+
let lastRefreshStartedAt: number | undefined;
|
|
36
|
+
let generation = 0;
|
|
37
|
+
|
|
38
|
+
const clearDelayedRefresh = () => {
|
|
39
|
+
if (!delayedRefresh) return;
|
|
40
|
+
clearTimeout(delayedRefresh);
|
|
41
|
+
delayedRefresh = undefined;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const runRefresh = (target: T) => {
|
|
45
|
+
clearDelayedRefresh();
|
|
46
|
+
if (refreshInFlight) {
|
|
47
|
+
refreshPending = true;
|
|
48
|
+
pendingTarget = target;
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const currentGeneration = generation;
|
|
53
|
+
refreshInFlight = true;
|
|
54
|
+
lastRefreshStartedAt = Date.now();
|
|
55
|
+
void refresh(target)
|
|
56
|
+
.catch(() => undefined)
|
|
57
|
+
.finally(() => {
|
|
58
|
+
if (currentGeneration !== generation) return;
|
|
59
|
+
refreshInFlight = false;
|
|
60
|
+
afterRefresh();
|
|
61
|
+
if (refreshPending) {
|
|
62
|
+
refreshPending = false;
|
|
63
|
+
const nextTarget = pendingTarget ?? target;
|
|
64
|
+
pendingTarget = undefined;
|
|
65
|
+
schedule(nextTarget);
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const schedule = (target: T, options: ScheduleProjectRefreshOptions = {}) => {
|
|
71
|
+
if (options.force || throttleMs <= 0 || lastRefreshStartedAt === undefined) {
|
|
72
|
+
runRefresh(target);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const delayMs = Math.max(0, throttleMs - (Date.now() - lastRefreshStartedAt));
|
|
77
|
+
if (delayMs === 0) {
|
|
78
|
+
runRefresh(target);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
pendingTarget = target;
|
|
83
|
+
if (delayedRefresh) return;
|
|
84
|
+
delayedRefresh = setTimeout(() => {
|
|
85
|
+
delayedRefresh = undefined;
|
|
86
|
+
const nextTarget = pendingTarget ?? target;
|
|
87
|
+
pendingTarget = undefined;
|
|
88
|
+
runRefresh(nextTarget);
|
|
89
|
+
}, delayMs);
|
|
90
|
+
delayedRefresh.unref?.();
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
schedule,
|
|
95
|
+
stop() {
|
|
96
|
+
generation += 1;
|
|
97
|
+
clearDelayedRefresh();
|
|
98
|
+
refreshInFlight = false;
|
|
99
|
+
refreshPending = false;
|
|
100
|
+
pendingTarget = undefined;
|
|
101
|
+
lastRefreshStartedAt = undefined;
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|