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.
@@ -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
- // Structural subset keeps mouse support optional on Pi versions before 0.85.
2
- export type EditorMouseEvent = {
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: FormatToken[],
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: FormatToken[],
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: FormatToken[],
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: FormatToken[]) => {
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: FormatToken[]): number[] {
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: FormatToken[],
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: FormatToken, renderVariable: (name: string) => string): string {
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: FormatToken & { kind: "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: FormatToken & { kind: "group" }): boolean {
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 CompactFormatChunk,
18
- collectFooterFormatReferences,
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(width: number, showQuota = true): string[] {
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 wideFormatTokens = config.components.footer.styles.starship.format
234
- ? parseFooterFormat(config.components.footer.styles.starship.format)
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 compactReferences = collectFooterFormatReferences(
244
- compactFormatTokens,
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.has("session_name")
297
+ ? wideReferences.includes("session_name")
297
298
  : config.components.footer.styles.starship.segments.sessionName) ||
298
- compactReferences.has("session_name");
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 { percent: contextPercent, contextWindow } = resolveContextUsage(
318
- ctx,
319
- hooks.getLiveContext?.(),
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 = (chunks: CompactFormatChunk[]): CompactLayoutChunk[] => {
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 = collectFooterFormatReferences(chunk.tokens, FOOTER_FORMAT_ALIASES);
944
+ const references = chunk.references;
931
945
  if (
932
- (!quotaLabel || !references.has("codex_quota")) &&
933
- ["cwd", "session_name", "git_branch"].some((name) => references.has(name))
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 = compileCompactFormatSplit(compactFormatTokens);
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 = ctx.model?.contextWindow ?? usage?.contextWindow;
476
+ const contextWindow = modelWindow ?? usage?.contextWindow;
402
477
  return {
403
478
  percent:
404
- live && contextWindow && contextWindow > 0
405
- ? (live.tokens / contextWindow) * 100
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
  };
@@ -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
- async function resolveGitPath(cwd: string, pathSpec: string): Promise<string | undefined> {
233
- try {
234
- const { stdout } = await execFileAsync("git", ["rev-parse", "--git-path", pathSpec], {
235
- cwd,
236
- timeout: GIT_COMMAND_TIMEOUT_MS,
237
- });
238
- const resolved = (typeof stdout === "string" ? stdout : String(stdout)).trim();
239
- if (!resolved) return undefined;
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
- const [rebaseMerge, rebaseApply, mergeHead, cherryPickHead, revertHead, bisectLog] =
251
- await Promise.all([
252
- resolveGitPath(cwd, "rebase-merge"),
253
- resolveGitPath(cwd, "rebase-apply"),
254
- resolveGitPath(cwd, "MERGE_HEAD"),
255
- resolveGitPath(cwd, "CHERRY_PICK_HEAD"),
256
- resolveGitPath(cwd, "REVERT_HEAD"),
257
- resolveGitPath(cwd, "BISECT_LOG"),
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
- * Both default to `false` so no extra git process is spawned unless a segment
286
- * is enabled and needs the data.
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
- execFileAsync("git", ["stash", "list"], {
315
- cwd,
316
- timeout: GIT_COMMAND_TIMEOUT_MS,
317
- }).catch(() => ({ stdout: "" })),
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: {