pi-zentui 0.26.0 → 0.26.1
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/docs/configuration.md +10 -0
- package/extensions/zentui/editor-mouse.ts +8 -2
- package/extensions/zentui/footer-format.ts +134 -9
- package/extensions/zentui/footer.ts +42 -28
- package/extensions/zentui/format.ts +81 -3
- package/extensions/zentui/git.ts +47 -30
- package/extensions/zentui/index.ts +118 -59
- package/extensions/zentui/live-context.ts +6 -1
- package/extensions/zentui/package-version.ts +30 -12
- package/extensions/zentui/project-demand.ts +43 -0
- package/extensions/zentui/project-discovery.ts +58 -0
- package/extensions/zentui/project-refresh.ts +10 -3
- package/extensions/zentui/project-state.ts +37 -0
- package/extensions/zentui/repository-root.ts +1 -0
- package/extensions/zentui/runtime.ts +75 -29
- package/extensions/zentui/state.ts +17 -7
- package/extensions/zentui/telemetry.ts +113 -11
- package/extensions/zentui/thinking-experimental.ts +151 -20
- package/extensions/zentui/thinking-steps.ts +20 -5
- package/extensions/zentui/ui.ts +12 -1
- package/extensions/zentui/working-line.ts +90 -45
- package/package.json +7 -7
package/docs/configuration.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
Zentui reads optional user configuration from `~/.pi/agent/zentui.json`. Missing or invalid known values fall back to defaults. Unknown fields are ignored at runtime but preserved on disk by component save operations where they are user-owned migration or future-style data.
|
|
6
6
|
|
|
7
|
+
## Refresh and cache freshness
|
|
8
|
+
|
|
9
|
+
- Settings telemetry checks file signatures on each demanded sync, including inode, size, modification and change times. Ordinary edits, replacements and trust changes reload the snapshot; untrusted project settings are never inspected. Signature equality assumes normal filesystem metadata updates, not content hashing. Read failures retry.
|
|
10
|
+
- Session usage reuses one event-owned snapshot under Pi's append-only session contract. Session/leaf changes and message, agent, settlement, tree and compaction boundaries refresh totals. Silent same-leaf history edits outside those boundaries are not detected by this fast path; the generic usage helper remains mutation-safe.
|
|
11
|
+
- Repository path rendering uses the latest controlled project refresh. Creating or removing a `.git` marker becomes visible on the next demanded event, explicit or interval refresh, not every render. Disabling polling leaves event/explicit refreshes active.
|
|
12
|
+
- Runtime and package discovery share one asynchronous filesystem snapshot per refresh; manifests are read fresh. Runtime versions retain the bounded 32-project cache, 60-second successful-version lifetime and 5-second failure retry, with marker and environment changes invalidating earlier results.
|
|
13
|
+
- Footer syntax uses a 32-entry exact-format cache. Format and alias edits are observed immediately; theme, width, status and quota output are never cached with syntax.
|
|
14
|
+
|
|
15
|
+
Disabled, native and unowned surfaces do not demand these probes on behalf of Zentui. Pi and other extensions may perform their own work.
|
|
16
|
+
|
|
7
17
|
## Start with minimal overrides
|
|
8
18
|
|
|
9
19
|
Do not copy the complete defaults into your file. Omitted fields keep defaults and source-aware inheritance. New installs enable Opencode Editor, Framed User messages, Zentui selector borders, and Starship Footer; Working line and Thinking (Experimental) are disabled.
|
|
@@ -1,11 +1,17 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import type { EditorComponent } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
type LegacyEditorMouseEvent = {
|
|
3
4
|
type: string;
|
|
4
5
|
x: number;
|
|
5
6
|
y: number;
|
|
6
7
|
width: number;
|
|
7
8
|
height: number;
|
|
8
9
|
};
|
|
10
|
+
// Use the installed Pi event contract without requiring mouse exports on pre-0.85 Pi.
|
|
11
|
+
type MouseEventFor<T> = T extends { handleMouse?: (event: infer Event) => unknown }
|
|
12
|
+
? Event
|
|
13
|
+
: LegacyEditorMouseEvent;
|
|
14
|
+
export type EditorMouseEvent = MouseEventFor<EditorComponent>;
|
|
9
15
|
export type EditorMouseResult = {
|
|
10
16
|
handled?: boolean;
|
|
11
17
|
capture?: boolean;
|
|
@@ -19,6 +19,131 @@ export type CompactFormatChunk =
|
|
|
19
19
|
| { kind: "tokens"; tokens: FormatToken[]; boundary: CompactBoundaryKind }
|
|
20
20
|
| { kind: "extensions"; boundary: CompactBoundaryKind };
|
|
21
21
|
|
|
22
|
+
/** Cached syntax is deeply readonly; public parser/compiler results remain mutable. */
|
|
23
|
+
export type ReadonlyFormatToken =
|
|
24
|
+
| Readonly<Exclude<FormatToken, { kind: "group" }>>
|
|
25
|
+
| { readonly kind: "group"; readonly tokens: readonly ReadonlyFormatToken[] };
|
|
26
|
+
|
|
27
|
+
type ReadonlyCompactChunk =
|
|
28
|
+
| {
|
|
29
|
+
readonly kind: "tokens";
|
|
30
|
+
readonly tokens: readonly ReadonlyFormatToken[];
|
|
31
|
+
readonly boundary: CompactBoundaryKind;
|
|
32
|
+
}
|
|
33
|
+
| { readonly kind: "extensions"; readonly boundary: CompactBoundaryKind };
|
|
34
|
+
|
|
35
|
+
export type CompiledCompactChunk = ReadonlyCompactChunk & {
|
|
36
|
+
readonly references: readonly string[];
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export type CompiledFooterFormat = {
|
|
40
|
+
readonly tokens: readonly ReadonlyFormatToken[];
|
|
41
|
+
readonly references: readonly string[];
|
|
42
|
+
readonly compact: {
|
|
43
|
+
readonly left: readonly CompiledCompactChunk[];
|
|
44
|
+
readonly right: readonly CompiledCompactChunk[];
|
|
45
|
+
readonly alignRight: boolean;
|
|
46
|
+
};
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
type SyntaxEntry = {
|
|
50
|
+
tokens: readonly ReadonlyFormatToken[];
|
|
51
|
+
compact: {
|
|
52
|
+
left: readonly ReadonlyCompactChunk[];
|
|
53
|
+
right: readonly ReadonlyCompactChunk[];
|
|
54
|
+
alignRight: boolean;
|
|
55
|
+
};
|
|
56
|
+
names: readonly string[];
|
|
57
|
+
canonicalNames?: readonly string[];
|
|
58
|
+
compiled?: CompiledFooterFormat;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// Format text only: no session, config object, rendered output, or theme is retained.
|
|
62
|
+
const syntaxCache = new Map<string, SyntaxEntry>();
|
|
63
|
+
const SYNTAX_CACHE_LIMIT = 32;
|
|
64
|
+
|
|
65
|
+
function freezeTokens(tokens: FormatToken[]): readonly ReadonlyFormatToken[] {
|
|
66
|
+
for (const token of tokens) {
|
|
67
|
+
if (token.kind === "group") freezeTokens(token.tokens);
|
|
68
|
+
Object.freeze(token);
|
|
69
|
+
}
|
|
70
|
+
return Object.freeze(tokens);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Share bounded syntax/analysis across rendering and dependency-demand checks. */
|
|
74
|
+
export function compiledFooterFormat(
|
|
75
|
+
format: string,
|
|
76
|
+
aliases: Readonly<Record<string, string>> = {},
|
|
77
|
+
): CompiledFooterFormat {
|
|
78
|
+
let entry = syntaxCache.get(format);
|
|
79
|
+
if (!entry) {
|
|
80
|
+
const tokens = parseFooterFormat(format);
|
|
81
|
+
const compact = compileCompactFormatSplit(tokens);
|
|
82
|
+
const names = new Set<string>();
|
|
83
|
+
const visit = (items: FormatToken[]) => {
|
|
84
|
+
for (const token of items) {
|
|
85
|
+
if (token.kind === "group") visit(token.tokens);
|
|
86
|
+
else if (token.kind === "var") names.add(token.name);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
visit(tokens);
|
|
90
|
+
const freezeChunks = (chunks: CompactFormatChunk[]) =>
|
|
91
|
+
Object.freeze(
|
|
92
|
+
chunks.map((chunk) => {
|
|
93
|
+
if (chunk.kind === "tokens") freezeTokens(chunk.tokens);
|
|
94
|
+
return Object.freeze(chunk);
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
entry = {
|
|
98
|
+
tokens: freezeTokens(tokens),
|
|
99
|
+
compact: Object.freeze({
|
|
100
|
+
left: freezeChunks(compact.left),
|
|
101
|
+
right: freezeChunks(compact.right),
|
|
102
|
+
alignRight: compact.alignRight,
|
|
103
|
+
}),
|
|
104
|
+
names: Object.freeze([...names]),
|
|
105
|
+
};
|
|
106
|
+
if (syntaxCache.size >= SYNTAX_CACHE_LIMIT) {
|
|
107
|
+
const oldest = syntaxCache.keys().next().value;
|
|
108
|
+
if (oldest !== undefined) syntaxCache.delete(oldest);
|
|
109
|
+
}
|
|
110
|
+
} else {
|
|
111
|
+
syntaxCache.delete(format);
|
|
112
|
+
}
|
|
113
|
+
syntaxCache.set(format, entry);
|
|
114
|
+
// Compare only names that occur in this syntax, including structural variables.
|
|
115
|
+
// This observes in-place alias edits without retaining mutable caller objects.
|
|
116
|
+
if (
|
|
117
|
+
entry.compiled &&
|
|
118
|
+
entry.names.every((name, i) => (aliases[name] ?? name) === entry.canonicalNames?.[i])
|
|
119
|
+
) {
|
|
120
|
+
return entry.compiled;
|
|
121
|
+
}
|
|
122
|
+
const references = (tokens: readonly ReadonlyFormatToken[]) =>
|
|
123
|
+
Object.freeze([...collectFooterFormatReferences(tokens, aliases)]);
|
|
124
|
+
const analyzeChunks = (chunks: readonly ReadonlyCompactChunk[]) =>
|
|
125
|
+
Object.freeze(
|
|
126
|
+
chunks.map((chunk) =>
|
|
127
|
+
Object.freeze({
|
|
128
|
+
...chunk,
|
|
129
|
+
references:
|
|
130
|
+
chunk.kind === "tokens" ? references(chunk.tokens) : Object.freeze([] as string[]),
|
|
131
|
+
}),
|
|
132
|
+
),
|
|
133
|
+
);
|
|
134
|
+
entry.canonicalNames = Object.freeze(entry.names.map((name) => aliases[name] ?? name));
|
|
135
|
+
entry.compiled = Object.freeze({
|
|
136
|
+
tokens: entry.tokens,
|
|
137
|
+
references: references(entry.tokens),
|
|
138
|
+
compact: Object.freeze({
|
|
139
|
+
left: analyzeChunks(entry.compact.left),
|
|
140
|
+
right: analyzeChunks(entry.compact.right),
|
|
141
|
+
alignRight: entry.compact.alignRight,
|
|
142
|
+
}),
|
|
143
|
+
});
|
|
144
|
+
return entry.compiled;
|
|
145
|
+
}
|
|
146
|
+
|
|
22
147
|
const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
23
148
|
|
|
24
149
|
/**
|
|
@@ -118,7 +243,7 @@ function parseTokenSlice(
|
|
|
118
243
|
* spaces are inserted — the user controls all spacing.
|
|
119
244
|
*/
|
|
120
245
|
export function renderFormatSplit(
|
|
121
|
-
tokens:
|
|
246
|
+
tokens: readonly ReadonlyFormatToken[],
|
|
122
247
|
renderVariable: (name: string) => string,
|
|
123
248
|
): { left: string; middle: string; right: string } {
|
|
124
249
|
const fillIndices = findTopLevelFillIndices(tokens);
|
|
@@ -224,18 +349,18 @@ function trimBoundaryWhitespace(tokens: FormatToken[]): FormatToken[] {
|
|
|
224
349
|
}
|
|
225
350
|
|
|
226
351
|
export function renderFormatTokens(
|
|
227
|
-
tokens:
|
|
352
|
+
tokens: readonly ReadonlyFormatToken[],
|
|
228
353
|
renderVariable: (name: string) => string,
|
|
229
354
|
): string {
|
|
230
355
|
return renderTokenSlice(tokens, 0, tokens.length, renderVariable);
|
|
231
356
|
}
|
|
232
357
|
|
|
233
358
|
export function collectFooterFormatReferences(
|
|
234
|
-
tokens:
|
|
359
|
+
tokens: readonly ReadonlyFormatToken[],
|
|
235
360
|
aliases: Record<string, string> = {},
|
|
236
361
|
): Set<string> {
|
|
237
362
|
const references = new Set<string>();
|
|
238
|
-
const visit = (items:
|
|
363
|
+
const visit = (items: readonly ReadonlyFormatToken[]) => {
|
|
239
364
|
for (const token of items) {
|
|
240
365
|
if (token.kind === "group") {
|
|
241
366
|
visit(token.tokens);
|
|
@@ -252,7 +377,7 @@ export function collectFooterFormatReferences(
|
|
|
252
377
|
return references;
|
|
253
378
|
}
|
|
254
379
|
|
|
255
|
-
function findTopLevelFillIndices(tokens:
|
|
380
|
+
function findTopLevelFillIndices(tokens: readonly ReadonlyFormatToken[]): number[] {
|
|
256
381
|
const fillIndices: number[] = [];
|
|
257
382
|
for (let index = 0; index < tokens.length; index++) {
|
|
258
383
|
if (tokens[index]?.kind === "fill") fillIndices.push(index);
|
|
@@ -261,7 +386,7 @@ function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
|
|
|
261
386
|
}
|
|
262
387
|
|
|
263
388
|
function renderTokenSlice(
|
|
264
|
-
tokens:
|
|
389
|
+
tokens: readonly ReadonlyFormatToken[],
|
|
265
390
|
start: number,
|
|
266
391
|
end: number,
|
|
267
392
|
renderVariable: (name: string) => string,
|
|
@@ -275,7 +400,7 @@ function renderTokenSlice(
|
|
|
275
400
|
return result;
|
|
276
401
|
}
|
|
277
402
|
|
|
278
|
-
function renderToken(token:
|
|
403
|
+
function renderToken(token: ReadonlyFormatToken, renderVariable: (name: string) => string): string {
|
|
279
404
|
if (token.kind === "text") return token.value;
|
|
280
405
|
if (token.kind === "var") return renderVariable(token.name);
|
|
281
406
|
if (token.kind === "fill") return "";
|
|
@@ -298,7 +423,7 @@ const NON_CONTENT_VARS = new Set(["sep", "separator"]);
|
|
|
298
423
|
* ignored for emptiness so orphan themed pipes do not force a group to render.
|
|
299
424
|
*/
|
|
300
425
|
function isGroupEmpty(
|
|
301
|
-
group:
|
|
426
|
+
group: ReadonlyFormatToken & { kind: "group" },
|
|
302
427
|
renderVariable: (name: string) => string,
|
|
303
428
|
): boolean {
|
|
304
429
|
let sawContentVarOrGroup = false;
|
|
@@ -317,7 +442,7 @@ function isGroupEmpty(
|
|
|
317
442
|
return sawContentVarOrGroup || groupOnlyNonContentVars(group);
|
|
318
443
|
}
|
|
319
444
|
|
|
320
|
-
function groupOnlyNonContentVars(group:
|
|
445
|
+
function groupOnlyNonContentVars(group: ReadonlyFormatToken & { kind: "group" }): boolean {
|
|
321
446
|
let sawSep = false;
|
|
322
447
|
for (const child of group.tokens) {
|
|
323
448
|
if (child.kind === "text") {
|
|
@@ -14,10 +14,8 @@ import {
|
|
|
14
14
|
sanitizeExtensionStatusText,
|
|
15
15
|
} from "./extension-status";
|
|
16
16
|
import {
|
|
17
|
-
type
|
|
18
|
-
|
|
19
|
-
compileCompactFormatSplit,
|
|
20
|
-
parseFooterFormat,
|
|
17
|
+
type CompiledCompactChunk,
|
|
18
|
+
compiledFooterFormat,
|
|
21
19
|
renderFormatSplit,
|
|
22
20
|
renderFormatTokens,
|
|
23
21
|
stripOrphanSeparators,
|
|
@@ -34,6 +32,7 @@ import { truncateFooterText } from "./footer-text";
|
|
|
34
32
|
import {
|
|
35
33
|
buildContextDisplayLabel,
|
|
36
34
|
buildSessionDurationLabel,
|
|
35
|
+
type ContextUsageSnapshot,
|
|
37
36
|
contextColorTier,
|
|
38
37
|
formatCwdLabel,
|
|
39
38
|
formatGitBranchText,
|
|
@@ -219,7 +218,11 @@ export function installFooter(
|
|
|
219
218
|
hooks.onDispose?.();
|
|
220
219
|
},
|
|
221
220
|
invalidate() {},
|
|
222
|
-
render: function renderFooter(
|
|
221
|
+
render: function renderFooter(
|
|
222
|
+
width: number,
|
|
223
|
+
showQuota = true,
|
|
224
|
+
contextSnapshot?: ContextUsageSnapshot,
|
|
225
|
+
): string[] {
|
|
223
226
|
if (width <= 0) return [""];
|
|
224
227
|
const config = getConfig();
|
|
225
228
|
const footer = config.components.footer;
|
|
@@ -230,20 +233,18 @@ export function installFooter(
|
|
|
230
233
|
let quotaLabel = styledQuota;
|
|
231
234
|
let quotaProbe = "";
|
|
232
235
|
const footerModelLabel = modelLabelFor(state, footer.modelLabel);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
: [];
|
|
236
|
-
const compactFormatTokens = config.components.footer.styles.starship.responsive
|
|
237
|
-
? parseFooterFormat(config.components.footer.styles.starship.compactFormat)
|
|
238
|
-
: [];
|
|
239
|
-
const wideReferences = collectFooterFormatReferences(
|
|
240
|
-
wideFormatTokens,
|
|
236
|
+
const wideFormat = compiledFooterFormat(
|
|
237
|
+
footer.styles.starship.format,
|
|
241
238
|
FOOTER_FORMAT_ALIASES,
|
|
242
239
|
);
|
|
243
|
-
const
|
|
244
|
-
|
|
240
|
+
const compactFormat = compiledFooterFormat(
|
|
241
|
+
footer.styles.starship.responsive ? footer.styles.starship.compactFormat : "",
|
|
245
242
|
FOOTER_FORMAT_ALIASES,
|
|
246
243
|
);
|
|
244
|
+
const wideFormatTokens = wideFormat.tokens;
|
|
245
|
+
const compactFormatTokens = compactFormat.tokens;
|
|
246
|
+
const wideReferences = wideFormat.references;
|
|
247
|
+
const compactReferences = compactFormat.references;
|
|
247
248
|
const colorSource = config.components.footer.colorSource;
|
|
248
249
|
const iconMode = config.icons.effectiveMode;
|
|
249
250
|
const pathDisplay = config.components.footer.styles.starship.pathDisplay;
|
|
@@ -293,9 +294,9 @@ export function installFooter(
|
|
|
293
294
|
);
|
|
294
295
|
const needsSessionName =
|
|
295
296
|
(config.components.footer.styles.starship.format
|
|
296
|
-
? wideReferences.
|
|
297
|
+
? wideReferences.includes("session_name")
|
|
297
298
|
: config.components.footer.styles.starship.segments.sessionName) ||
|
|
298
|
-
compactReferences.
|
|
299
|
+
compactReferences.includes("session_name");
|
|
299
300
|
const sessionName = needsSessionName
|
|
300
301
|
? sanitizeExtensionStatusText(ctx.sessionManager.getSessionName() ?? "")
|
|
301
302
|
: "";
|
|
@@ -314,10 +315,21 @@ export function installFooter(
|
|
|
314
315
|
config.components.footer.styles.starship.gitBranch.maxLength,
|
|
315
316
|
)
|
|
316
317
|
: undefined;
|
|
317
|
-
const
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
318
|
+
const needsContext =
|
|
319
|
+
(config.components.footer.styles.starship.format
|
|
320
|
+
? wideReferences.includes("context")
|
|
321
|
+
: config.components.footer.styles.starship.segments.context) ||
|
|
322
|
+
compactReferences.includes("context") ||
|
|
323
|
+
(state.autoCompaction &&
|
|
324
|
+
(wideReferences.includes("auto_compaction") ||
|
|
325
|
+
compactReferences.includes("auto_compaction")));
|
|
326
|
+
// Reuse only within this render's quota fallback, including resolved unknown usage.
|
|
327
|
+
const context =
|
|
328
|
+
contextSnapshot ??
|
|
329
|
+
(needsContext
|
|
330
|
+
? resolveContextUsage(ctx, hooks.getLiveContext?.())
|
|
331
|
+
: { percent: undefined, contextWindow: undefined });
|
|
332
|
+
const { percent: contextPercent, contextWindow } = context;
|
|
321
333
|
const contextLabel = buildContextDisplayLabel({
|
|
322
334
|
percent: contextPercent,
|
|
323
335
|
contextWindow,
|
|
@@ -794,7 +806,7 @@ export function installFooter(
|
|
|
794
806
|
(value) => !otherText.includes(value),
|
|
795
807
|
);
|
|
796
808
|
// Bounded fail-open fallback if every candidate collides with non-quota text.
|
|
797
|
-
if (!letter) return renderFooter(width, false);
|
|
809
|
+
if (!letter) return renderFooter(width, false, context);
|
|
798
810
|
quotaProbe = codexQuotaText(quota).replace(/[a-z0-9]/gi, letter);
|
|
799
811
|
quotaLabel = styledQuota.replace(codexQuotaText(quota), quotaProbe);
|
|
800
812
|
}
|
|
@@ -841,7 +853,7 @@ export function installFooter(
|
|
|
841
853
|
0,
|
|
842
854
|
);
|
|
843
855
|
// Recompose without quota if any occurrence was clipped, split, or omitted.
|
|
844
|
-
if (count(source) !== count(framedRows)) return renderFooter(width, false);
|
|
856
|
+
if (count(source) !== count(framedRows)) return renderFooter(width, false, context);
|
|
845
857
|
return framedRows.map((row) => row.replaceAll(quotaProbe, codexQuotaText(quota)));
|
|
846
858
|
}
|
|
847
859
|
return framedRows;
|
|
@@ -907,7 +919,9 @@ export function installFooter(
|
|
|
907
919
|
return renderVariable(name);
|
|
908
920
|
}
|
|
909
921
|
};
|
|
910
|
-
const renderCompactChunks = (
|
|
922
|
+
const renderCompactChunks = (
|
|
923
|
+
chunks: readonly CompiledCompactChunk[],
|
|
924
|
+
): CompactLayoutChunk[] => {
|
|
911
925
|
const compactChunks: CompactLayoutChunk[] = [];
|
|
912
926
|
for (const chunk of chunks) {
|
|
913
927
|
if (chunk.kind === "extensions") {
|
|
@@ -927,10 +941,10 @@ export function installFooter(
|
|
|
927
941
|
let rendered = stripOrphanSeparators(
|
|
928
942
|
renderFormatTokens(chunk.tokens, renderCompactVariable),
|
|
929
943
|
);
|
|
930
|
-
const references =
|
|
944
|
+
const references = chunk.references;
|
|
931
945
|
if (
|
|
932
|
-
(!quotaLabel || !references.
|
|
933
|
-
["cwd", "session_name", "git_branch"].some((name) => references.
|
|
946
|
+
(!quotaLabel || !references.includes("codex_quota")) &&
|
|
947
|
+
["cwd", "session_name", "git_branch"].some((name) => references.includes(name))
|
|
934
948
|
) {
|
|
935
949
|
rendered = truncateFooterText(rendered, chunkBudget, "…");
|
|
936
950
|
}
|
|
@@ -938,7 +952,7 @@ export function installFooter(
|
|
|
938
952
|
}
|
|
939
953
|
return compactChunks;
|
|
940
954
|
};
|
|
941
|
-
const compact =
|
|
955
|
+
const compact = compactFormat.compact;
|
|
942
956
|
const compactLeft = renderCompactChunks(compact.left);
|
|
943
957
|
const compactRight = renderCompactChunks(compact.right);
|
|
944
958
|
return frameRows(
|
|
@@ -295,6 +295,74 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
|
|
|
295
295
|
return totals;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
const EMPTY_USAGE_TOTALS: UsageTotals = Object.freeze({
|
|
299
|
+
input: 0,
|
|
300
|
+
output: 0,
|
|
301
|
+
cacheRead: 0,
|
|
302
|
+
cacheWrite: 0,
|
|
303
|
+
cost: 0,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Production-only snapshot for Pi's append-only session contract. The owner must invalidate
|
|
308
|
+
* at message/agent completion, settlement, tree/compaction and session boundaries. Leaf IDs
|
|
309
|
+
* supplement those events; they are NOT a full-history revision. Silent same-leaf in-place
|
|
310
|
+
* edits outside those boundaries require the generic mutation-safe getUsageTotals instead.
|
|
311
|
+
*/
|
|
312
|
+
export class EventUsageTotalsController {
|
|
313
|
+
private snapshot?: {
|
|
314
|
+
manager: ExtensionContext["sessionManager"];
|
|
315
|
+
sessionId: string;
|
|
316
|
+
leafId: string | null;
|
|
317
|
+
totals: UsageTotals;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
invalidate(): void {
|
|
321
|
+
this.snapshot = undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
resolve(ctx: ExtensionContext, demanded: boolean): UsageTotals {
|
|
325
|
+
if (!demanded) {
|
|
326
|
+
this.invalidate();
|
|
327
|
+
return EMPTY_USAGE_TOTALS;
|
|
328
|
+
}
|
|
329
|
+
const manager = ctx.sessionManager;
|
|
330
|
+
let identity: { sessionId: string; leafId: string | null } | undefined;
|
|
331
|
+
try {
|
|
332
|
+
if (
|
|
333
|
+
typeof manager.getEntries === "function" &&
|
|
334
|
+
typeof manager.getSessionId === "function" &&
|
|
335
|
+
typeof manager.getLeafId === "function"
|
|
336
|
+
) {
|
|
337
|
+
const sessionId = manager.getSessionId();
|
|
338
|
+
const leafId = manager.getLeafId();
|
|
339
|
+
if (
|
|
340
|
+
typeof sessionId === "string" &&
|
|
341
|
+
sessionId.length > 0 &&
|
|
342
|
+
(leafId === null || (typeof leafId === "string" && leafId.length > 0))
|
|
343
|
+
)
|
|
344
|
+
identity = { sessionId, leafId };
|
|
345
|
+
}
|
|
346
|
+
} catch {
|
|
347
|
+
// Older or unsupported hosts retain the generic mutation-safe path.
|
|
348
|
+
}
|
|
349
|
+
if (!identity) {
|
|
350
|
+
this.invalidate();
|
|
351
|
+
return getUsageTotals(ctx);
|
|
352
|
+
}
|
|
353
|
+
if (
|
|
354
|
+
this.snapshot?.manager === manager &&
|
|
355
|
+
this.snapshot.sessionId === identity.sessionId &&
|
|
356
|
+
this.snapshot.leafId === identity.leafId
|
|
357
|
+
)
|
|
358
|
+
return this.snapshot.totals;
|
|
359
|
+
this.invalidate();
|
|
360
|
+
const totals = computeUsageTotals(manager.getEntries() as readonly SessionEntry[]);
|
|
361
|
+
this.snapshot = { manager, ...identity, totals };
|
|
362
|
+
return totals;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
298
366
|
export function buildCacheReadLabel(cacheRead: number): string {
|
|
299
367
|
return cacheRead > 0 ? `R${formatCount(cacheRead)}` : "";
|
|
300
368
|
}
|
|
@@ -397,12 +465,22 @@ export function resolveContextUsage(
|
|
|
397
465
|
ctx: Pick<ExtensionContext, "model" | "getContextUsage">,
|
|
398
466
|
live?: { tokens: number },
|
|
399
467
|
): ContextUsageSnapshot {
|
|
468
|
+
const liveTokens =
|
|
469
|
+
live && Number.isFinite(live.tokens) && live.tokens >= 0 ? live.tokens : undefined;
|
|
470
|
+
const modelWindow = ctx.model?.contextWindow;
|
|
471
|
+
// Live provider usage is authoritative; avoid rebuilding the host session projection.
|
|
472
|
+
if (liveTokens !== undefined && modelWindow && Number.isFinite(modelWindow) && modelWindow > 0) {
|
|
473
|
+
return { percent: (liveTokens / modelWindow) * 100, contextWindow: modelWindow };
|
|
474
|
+
}
|
|
400
475
|
const usage = ctx.getContextUsage();
|
|
401
|
-
const contextWindow =
|
|
476
|
+
const contextWindow = modelWindow ?? usage?.contextWindow;
|
|
402
477
|
return {
|
|
403
478
|
percent:
|
|
404
|
-
|
|
405
|
-
|
|
479
|
+
liveTokens !== undefined &&
|
|
480
|
+
contextWindow &&
|
|
481
|
+
Number.isFinite(contextWindow) &&
|
|
482
|
+
contextWindow > 0
|
|
483
|
+
? (liveTokens / contextWindow) * 100
|
|
406
484
|
: (usage?.percent ?? undefined),
|
|
407
485
|
contextWindow,
|
|
408
486
|
};
|
package/extensions/zentui/git.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
4
|
import { promisify } from "node:util";
|
|
5
5
|
|
|
6
6
|
const execFileAsync = promisify(execFile);
|
|
@@ -229,33 +229,44 @@ export function detectGitState(paths: GitStatePaths): {
|
|
|
229
229
|
return {};
|
|
230
230
|
}
|
|
231
231
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return resolved.startsWith("/") ? resolved : join(cwd, resolved);
|
|
241
|
-
} catch {
|
|
242
|
-
return undefined;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
232
|
+
const GIT_OPERATION_PATHS = [
|
|
233
|
+
"rebase-merge",
|
|
234
|
+
"rebase-apply",
|
|
235
|
+
"MERGE_HEAD",
|
|
236
|
+
"CHERRY_PICK_HEAD",
|
|
237
|
+
"REVERT_HEAD",
|
|
238
|
+
"BISECT_LOG",
|
|
239
|
+
] as const;
|
|
245
240
|
|
|
246
241
|
async function readGitOperationState(cwd: string): Promise<{
|
|
247
242
|
gitState?: GitOperationState;
|
|
248
243
|
gitStateLabel?: string;
|
|
249
244
|
}> {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
245
|
+
let paths: string[];
|
|
246
|
+
try {
|
|
247
|
+
const { stdout } = await execFileAsync(
|
|
248
|
+
"git",
|
|
249
|
+
[
|
|
250
|
+
"rev-parse",
|
|
251
|
+
"--path-format=absolute",
|
|
252
|
+
...GIT_OPERATION_PATHS.flatMap((path) => ["--git-path", path]),
|
|
253
|
+
],
|
|
254
|
+
{ cwd, timeout: GIT_COMMAND_TIMEOUT_MS },
|
|
255
|
+
);
|
|
256
|
+
// rev-parse is line-delimited, not NUL-delimited. Reject ambiguous paths
|
|
257
|
+
// (including embedded newlines) rather than assigning another operation's path.
|
|
258
|
+
paths = String(stdout)
|
|
259
|
+
.replace(/\r?\n$/, "")
|
|
260
|
+
.split(/\r?\n/);
|
|
261
|
+
if (
|
|
262
|
+
paths.length !== GIT_OPERATION_PATHS.length ||
|
|
263
|
+
paths.some((path) => !isAbsolute(path) || /[\r\n\0]/.test(path))
|
|
264
|
+
)
|
|
265
|
+
return {};
|
|
266
|
+
} catch {
|
|
267
|
+
return {};
|
|
268
|
+
}
|
|
269
|
+
const [rebaseMerge, rebaseApply, mergeHead, cherryPickHead, revertHead, bisectLog] = paths;
|
|
259
270
|
|
|
260
271
|
const existing = (path: string | undefined) => (path && existsSync(path) ? path : undefined);
|
|
261
272
|
const rebaseDir = existing(rebaseMerge) ?? existing(rebaseApply);
|
|
@@ -282,10 +293,14 @@ function isNotARepoError(error: unknown): boolean {
|
|
|
282
293
|
|
|
283
294
|
/**
|
|
284
295
|
* Options for the optional probes that piggyback on the existing git refresh.
|
|
285
|
-
*
|
|
286
|
-
*
|
|
296
|
+
* Tag/metrics default to false; stash/operation state retain the full-status
|
|
297
|
+
* default for standalone callers. UI consumers may omit probes they do not display.
|
|
287
298
|
*/
|
|
288
299
|
export type ReadGitStatusOptions = {
|
|
300
|
+
/** Read stash counts (default true). */
|
|
301
|
+
readStash?: boolean;
|
|
302
|
+
/** Resolve and inspect operation paths (default true). */
|
|
303
|
+
readOperationState?: boolean;
|
|
289
304
|
/** Run `git describe --tags --exact-match HEAD` for the git_commit segment. */
|
|
290
305
|
readExactTag?: boolean;
|
|
291
306
|
/**
|
|
@@ -311,10 +326,12 @@ export async function readGitStatus(
|
|
|
311
326
|
cwd,
|
|
312
327
|
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
313
328
|
}),
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
329
|
+
options.readStash !== false
|
|
330
|
+
? execFileAsync("git", ["stash", "list"], {
|
|
331
|
+
cwd,
|
|
332
|
+
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
333
|
+
}).catch(() => ({ stdout: "" }))
|
|
334
|
+
: Promise.resolve({ stdout: "" }),
|
|
318
335
|
readExactTag
|
|
319
336
|
? execFileAsync("git", ["describe", "--tags", "--exact-match", "HEAD"], {
|
|
320
337
|
cwd,
|
|
@@ -356,7 +373,7 @@ export async function readGitStatus(
|
|
|
356
373
|
status.metrics = parseGitNumstat(metricsStdout);
|
|
357
374
|
}
|
|
358
375
|
}
|
|
359
|
-
const operation = await readGitOperationState(cwd);
|
|
376
|
+
const operation = options.readOperationState !== false ? await readGitOperationState(cwd) : {};
|
|
360
377
|
return {
|
|
361
378
|
kind: "ok",
|
|
362
379
|
status: {
|