pi-zentui 0.26.0 → 0.27.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/docs/configuration.md +15 -2
- package/extensions/zentui/config.ts +16 -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 +261 -100
- package/extensions/zentui/live-context.ts +6 -1
- package/extensions/zentui/minimalist-editor.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/settings-command.ts +48 -0
- 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 +153 -29
- package/extensions/zentui/working-line.ts +231 -51
- 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.
|
|
@@ -215,7 +225,8 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
|
|
|
215
225
|
"contextThresholds": {
|
|
216
226
|
"warning": 70,
|
|
217
227
|
"error": 90
|
|
218
|
-
}
|
|
228
|
+
},
|
|
229
|
+
"separator": "dash"
|
|
219
230
|
}
|
|
220
231
|
}
|
|
221
232
|
},
|
|
@@ -236,6 +247,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
|
|
|
236
247
|
},
|
|
237
248
|
"workingLine": {
|
|
238
249
|
"enabled": false,
|
|
250
|
+
"placement": "above",
|
|
239
251
|
"turnSummary": true,
|
|
240
252
|
"spinner": "star-bloom",
|
|
241
253
|
"spinnerIntervalMs": 100,
|
|
@@ -412,6 +424,7 @@ Reference only—not a starter file. Prefer the minimal overrides above. Optiona
|
|
|
412
424
|
- `components.thinkingSteps` independently owns opt-in **Thinking (Experimental)** display. It defaults to `{ "enabled": false, "mode": "tree" }`; canonical modes are `rail | tree | streaming`. The former persisted `streaming-experimental` value is accepted only as a migration alias and is normalized to `streaming` on save.
|
|
413
425
|
- All three modes decorate Pi's private host renderer and are tested on exact Pi versions 0.80.5, 0.82.1, 0.83.0, 0.84.0, 0.84.4, and 0.85.1. Active Streaming can switch live to Rail or Tree, and Rail and Tree can switch live between each other. Entering Streaming from a structural mode, first enable, and re-enable after live disable require restart; live disable restores native thinking. Disabled mode changes only preconfigure.
|
|
414
426
|
- `components.workingLine.enabled` is the sole Working-line ownership switch. Thinking (Experimental) never enables, configures, or owns the Working line and leaves the existing **Thinking time** option unchanged.
|
|
427
|
+
- `components.workingLine.placement` (`above | border`) selects the default floating row or the Minimalist/Opencode top border. Accent Rail supports only Above. Disabled, native, unavailable, or unsafe/narrow editors and Pi versions without working-row visibility control fall back to Above without changing saved choices. Legacy `input` normalizes to `above`; the prompt remains available for typing.
|
|
415
428
|
- `components.selectorBorders` owns selector-border enablement, fixed `zentui` style, and color source. Disable it for native Pi behavior.
|
|
416
429
|
- `components.footer` owns `native | starship | hidden` style selection, color source, model label, and Starship options. Hidden hides its main segments, retaining only allowed extension statuses; an empty status line occupies no rows.
|
|
417
430
|
- Starship's package-version segment reads the project manifest and is distinct from the runtime segment, which reports the installed toolchain.
|
|
@@ -521,7 +534,7 @@ Set `ZENTUI_DEBUG=1` when launching Pi to log the workaround diagnostic without
|
|
|
521
534
|
|
|
522
535
|
Set `components.editor.style` to `minimalist` or select it in `/zentui`. The rounded frame places viewport counts, Bash state, current/completed turn duration, and explicit session name at top left; cost, model, thinking, context, and optional latest-prompt cache hit rate at top right; viewport count plus Git at bottom left; and configured path at bottom right. Unnamed sessions add no placeholder.
|
|
523
536
|
|
|
524
|
-
Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable.
|
|
537
|
+
Path examples are `src` (`compact`), `zentui/src` (`project`), and `~/Projects/zentui/src` (`full`). Context can render as `11%`, `11%/372k`, or, with the gauge enabled and enough room, `[█░░░░] 11%/372k`. Enable `showCacheHit` to append values such as `Cache 98.2%`; it defaults to `false`, omits missing data, yields before context at narrow widths, and remains independent of Footer. The gauge shortens or disappears before the context text at narrow widths. Session name, timer, cost, cache hit rate, and Git can be hidden independently; model, thinking, and context remain structurally stable. Metadata items are joined with dashes by default (`separator: "dash"`) or dots (`separator: "dot"`).
|
|
525
538
|
|
|
526
539
|
Autocomplete stays inside the frame when Pi output can be split safely. Unknown third-party layouts fail open. Footer visibility remains independently controlled by `components.footer.style`; Minimalist does not remove Pi's header.
|
|
527
540
|
|
|
@@ -54,10 +54,12 @@ export type WorkingLineSpinner =
|
|
|
54
54
|
| "claude-inspired"
|
|
55
55
|
| "pulse";
|
|
56
56
|
export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
|
|
57
|
+
export type WorkingLinePlacement = "above" | "border";
|
|
57
58
|
export type ThinkingStepsMode = "rail" | "tree" | "streaming";
|
|
58
59
|
export type ComponentStyleOwner = "editor" | "userMessages" | "selectorBorders" | "footer";
|
|
59
60
|
export type MinimalistPathDisplayMode = "compact" | "project" | "full";
|
|
60
61
|
export type MinimalistContextFormat = "percent" | "percent-total";
|
|
62
|
+
export type MinimalistEditorSeparator = "dash" | "dot";
|
|
61
63
|
export type EditorBorderColorMode = "static" | "adaptive";
|
|
62
64
|
export type CompletionMenuStyle = "native" | "palette";
|
|
63
65
|
export type CompactFooterMaxLines = 1 | 2 | 3 | "unlimited";
|
|
@@ -142,6 +144,7 @@ export type MinimalistEditorStyleConfig = {
|
|
|
142
144
|
showCacheHit: boolean;
|
|
143
145
|
showGit: boolean;
|
|
144
146
|
contextThresholds: ContextThresholds;
|
|
147
|
+
separator: MinimalistEditorSeparator;
|
|
145
148
|
};
|
|
146
149
|
|
|
147
150
|
/** Temporary name retained for existing settings consumers. */
|
|
@@ -258,6 +261,7 @@ export type WorkingLineComponentConfig = {
|
|
|
258
261
|
colorSource: ColorSource;
|
|
259
262
|
messages: WorkingLineMessagesConfig;
|
|
260
263
|
segments: WorkingLineSegmentsConfig;
|
|
264
|
+
placement: WorkingLinePlacement;
|
|
261
265
|
};
|
|
262
266
|
|
|
263
267
|
export type WorkingLineComponentPatch = Partial<
|
|
@@ -498,6 +502,7 @@ const defaultMinimalistStyle: MinimalistEditorStyleConfig = {
|
|
|
498
502
|
showCacheHit: false,
|
|
499
503
|
showGit: true,
|
|
500
504
|
contextThresholds: { warning: 70, error: 90 },
|
|
505
|
+
separator: "dash",
|
|
501
506
|
};
|
|
502
507
|
|
|
503
508
|
const defaultStarshipStyle: StarshipFooterStyleConfig = {
|
|
@@ -557,6 +562,7 @@ const defaultComponents: ComponentsConfig = {
|
|
|
557
562
|
colorSource: "theme",
|
|
558
563
|
messages: { custom: true, values: [...PI_WORKING_LINE_MESSAGES] },
|
|
559
564
|
segments: { tool: true, elapsed: true, thought: true, tokens: true },
|
|
565
|
+
placement: "above",
|
|
560
566
|
},
|
|
561
567
|
selectorBorders: { enabled: true, style: "zentui", colorSource: "theme" },
|
|
562
568
|
footer: {
|
|
@@ -1405,6 +1411,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
|
|
|
1405
1411
|
showCacheHit: parseBoolean(minimalist.showCacheHit, defaultMinimalistStyle.showCacheHit),
|
|
1406
1412
|
showGit: parseBoolean(minimalist.showGit, defaultMinimalistStyle.showGit),
|
|
1407
1413
|
contextThresholds: minimalistThresholds,
|
|
1414
|
+
separator:
|
|
1415
|
+
minimalist.separator === "dash" || minimalist.separator === "dot"
|
|
1416
|
+
? minimalist.separator
|
|
1417
|
+
: defaultMinimalistStyle.separator,
|
|
1408
1418
|
},
|
|
1409
1419
|
},
|
|
1410
1420
|
},
|
|
@@ -1466,6 +1476,10 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
|
|
|
1466
1476
|
workingLine.textAnimation === "disabled"
|
|
1467
1477
|
? workingLine.textAnimation
|
|
1468
1478
|
: defaultComponents.workingLine.textAnimation,
|
|
1479
|
+
placement:
|
|
1480
|
+
workingLine.placement === "border"
|
|
1481
|
+
? workingLine.placement
|
|
1482
|
+
: defaultComponents.workingLine.placement,
|
|
1469
1483
|
colorSource: parseColorSource(
|
|
1470
1484
|
workingLine.colorSource,
|
|
1471
1485
|
defaultComponents.workingLine.colorSource,
|
|
@@ -1903,6 +1917,7 @@ function applyMinimalistStylePatch(
|
|
|
1903
1917
|
if (patch.showCost !== undefined) style.showCost = patch.showCost;
|
|
1904
1918
|
if (patch.showCacheHit !== undefined) style.showCacheHit = patch.showCacheHit;
|
|
1905
1919
|
if (patch.showGit !== undefined) style.showGit = patch.showGit;
|
|
1920
|
+
if (patch.separator !== undefined) style.separator = patch.separator;
|
|
1906
1921
|
if (patch.contextThresholds !== undefined) {
|
|
1907
1922
|
style.contextThresholds = { ...style.contextThresholds, ...patch.contextThresholds };
|
|
1908
1923
|
}
|
|
@@ -1970,6 +1985,7 @@ export function saveWorkingLineComponentPatch(
|
|
|
1970
1985
|
if (patch.textIntervalMs !== undefined) component.textIntervalMs = patch.textIntervalMs;
|
|
1971
1986
|
if (patch.textAnimation !== undefined) component.textAnimation = patch.textAnimation;
|
|
1972
1987
|
if (patch.colorSource !== undefined) component.colorSource = patch.colorSource;
|
|
1988
|
+
if (patch.placement !== undefined) component.placement = patch.placement;
|
|
1973
1989
|
if (patch.messages?.custom !== undefined) component.messages.custom = patch.messages.custom;
|
|
1974
1990
|
if (patch.messages?.values !== undefined) {
|
|
1975
1991
|
component.messages.values = normalizeWorkingLineMessages([...patch.messages.values]);
|
|
@@ -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
|
};
|