pi-zentui 0.21.0 → 0.22.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/README.md CHANGED
@@ -14,14 +14,15 @@
14
14
 
15
15
  ## What is this?
16
16
 
17
- Zentui gives four Pi surfaces independent ownership and styling:
17
+ Zentui gives Pi surfaces independent, opt-in treatments:
18
18
 
19
19
  - **Editor** — Opencode, Opencode copy-friendly, Accent Rail, and Minimalist input treatments
20
20
  - **User messages** — framed, framed copy-friendly, compact, and labeled transcript messages
21
+ - **Thinking (Experimental)** — optional Rail, Tree, or Streaming private thinking renderers, without owning the Working line
21
22
  - **Working line** — optional ownership of Pi's complete in-progress row and settled turn summary
22
23
  - **Footer** — Pi's native Footer, a Starship-style statusline, or a hidden zero-row Footer
23
24
 
24
- Editor, User messages, Working line, and selector borders have independent `enabled` fields. Footer uses one `style`: `native`, `starship`, or `hidden`. Use `/zentui` to configure each component without coupling it to the others.
25
+ Editor, User messages, Thinking (Experimental), Working line, and selector borders have independent `enabled` fields. Footer uses one `style`: `native`, `starship`, or `hidden`. Use `/zentui` to configure each component without coupling it to the others.
25
26
 
26
27
  ## Highlights
27
28
 
@@ -29,6 +30,7 @@ Editor, User messages, Working line, and selector borders have independent `enab
29
30
  | --- | --- | --- |
30
31
  | Editor | `opencode` | Opencode, copy-friendly, Accent Rail, Minimalist |
31
32
  | User messages | `framed` | Framed, copy-friendly, Compact, Labeled |
33
+ | Thinking (Experimental) | disabled (`tree`) | Rail, Tree, Streaming |
32
34
  | Working line | disabled | Five spinner presets, live tool/time/thinking/token segments, turn summary |
33
35
  | Footer | `starship` | Native, Starship, Hidden |
34
36
  | Selector borders | `zentui` | Independent enablement and color source |
@@ -87,7 +89,7 @@ pi install git:github.com/lmilojevicc/pi-zentui
87
89
 
88
90
  ## Configure
89
91
 
90
- Run `/zentui` inside Pi to configure Appearance, Editor, User messages, Working line, Footer, Segments, Git, and Extensions. Use `Tab` and `Shift+Tab` to switch sections. Changes are applied live and saved to:
92
+ Run `/zentui` inside Pi to configure Appearance, Editor, User messages, Thinking (Experimental), Working line, Footer, Segments, Git, and Extensions. Use `Tab` and `Shift+Tab` to switch sections. Most changes apply live; Thinking (Experimental) changes are saved and require restarting Pi. Configuration is saved to:
91
93
 
92
94
  ```text
93
95
  ~/.pi/agent/zentui.json
@@ -106,6 +108,10 @@ A small starter config:
106
108
  "enabled": true,
107
109
  "style": "framed"
108
110
  },
111
+ "thinkingSteps": {
112
+ "enabled": false,
113
+ "mode": "tree"
114
+ },
109
115
  "workingLine": {
110
116
  "enabled": false
111
117
  },
@@ -124,6 +130,8 @@ Detailed reference:
124
130
  - [Configuration, component styles, defaults, runtime detection, and compatibility](https://github.com/lmilojevicc/pi-zentui/blob/main/docs/configuration.md)
125
131
  - [Footer format template and variables](https://github.com/lmilojevicc/pi-zentui/blob/main/docs/footer-format.md)
126
132
 
133
+ The Starship Footer path defaults to `basename`. Opt into `components.footer.styles.starship.pathDisplay.mode: "repository"` to omit the repository directory itself: the repository root renders `.`, while `/repo/extensions/zentui` renders `extensions/zentui`. `depth` keeps the final N components in `full` and `repository` modes; `0` is unlimited. Until a current, safely contained repository root is available, repository mode silently uses the unlimited `full` path with `~` home abbreviation.
134
+
127
135
  Useful shortcuts:
128
136
 
129
137
  ```text
@@ -136,6 +144,12 @@ Useful shortcuts:
136
144
  /zentui format clear
137
145
  ```
138
146
 
147
+ **Thinking (Experimental)** uses one restart-gated private `AssistantMessageComponent` renderer for Rail, Tree, and Streaming, tested against exact Pi versions 0.80.5, 0.83.0, 0.84.0, and 0.84.4. It is disabled by default and may break after Pi updates. Every enable, disable, or mode change is saved but does not alter the active startup snapshot; restart Pi to apply it. Zentui installs an enabled startup snapshot before transcript restoration. Missing constructors, incompatible child layouts, parser limits, theme/render/width errors, or displaced patch ownership fail open to complete native thinking rather than switching modes.
148
+
149
+ Rail shows every parsed label in each native contiguous thinking run (`│ Label`, with only the open final phase shown as `│ • Label`). Tree independently shows the latest five labels in each run (`├─ · Label`, settled `└─ · Label`, open `└─ • Label`); it never aggregates across intervening text or tool blocks. Labels are rendered by fresh host-shaped Pi Markdown instances before cropping, so emphasis, code, links, HTML, LaTeX, custom transforms, and native `thinkingText` styling remain host-controlled. Every label occupies one terminal row: ANSI/OSC/grapheme-aware cropping adds `…` only when needed. Native horizontal padding stays external. Connectors are styled directly with the current theme's `accent` callback on every render, so custom themes control them independently. Hidden native thinking remains hidden and keeps Pi's native hidden label.
150
+
151
+ Streaming retains Pi's host-rendered final five rows under `Thinking 7.1s`, folds completed reasoning under `Thought` or current-session `Thought for 12.3s`, and owns the configured thinking-toggle binding (Ctrl+T by default) only for an active Streaming startup. Restored completions cannot recover a duration because Pi does not persist the thinking-end timestamp. Expand/refold and lifecycle tracking are bounded to 256 retained assistant components; evicted entries are first restored natively. All modes restore/dispose on shutdown. Thinking (Experimental) never writes the Working line and does not change its existing **Thinking time** option, working text, Footer, Editor, statuses, or model behavior.
152
+
139
153
  Pi 0.84 also provides a native fullscreen TUI with a sticky editor and Footer. Zentui does not enable it automatically; select fullscreen from Pi's `/settings`, set `"tuiMode": "fullscreen"` in Pi settings, or launch Pi with `--tui-mode fullscreen`.
140
154
 
141
155
  ## Requirements
@@ -46,6 +46,7 @@ export type WorkingLineSpinner =
46
46
  | "claude-inspired"
47
47
  | "pulse";
48
48
  export type WorkingLineTextAnimation = "classic" | "kitt" | "disabled";
49
+ export type ThinkingStepsMode = "rail" | "tree" | "streaming";
49
50
  export type ComponentStyleOwner = "editor" | "userMessages" | "selectorBorders" | "footer";
50
51
  export type MinimalistPathDisplayMode = "compact" | "project" | "full";
51
52
  export type MinimalistContextFormat = "percent" | "percent-total";
@@ -61,11 +62,11 @@ export type ContextThresholds = {
61
62
  error: number;
62
63
  };
63
64
 
64
- export type PathDisplayMode = "basename" | "full";
65
+ export type PathDisplayMode = "basename" | "full" | "repository";
65
66
 
66
67
  export type PathDisplayConfig = {
67
68
  mode: PathDisplayMode;
68
- /** Trailing directories to show in full mode. 0 = unlimited; clamped to 0..5. */
69
+ /** Final components to show in full/repository mode. 0 = unlimited; clamped to 0..5. */
69
70
  depth: number;
70
71
  };
71
72
 
@@ -250,9 +251,15 @@ export type WorkingLineComponentPatch = Partial<
250
251
  segments?: Partial<WorkingLineSegmentsConfig>;
251
252
  };
252
253
 
254
+ export type ThinkingStepsComponentConfig = {
255
+ enabled: boolean;
256
+ mode: ThinkingStepsMode;
257
+ };
258
+
253
259
  export type ComponentsConfig = {
254
260
  editor: EditorComponentConfig;
255
261
  userMessages: UserMessagesComponentConfig;
262
+ thinkingSteps: ThinkingStepsComponentConfig;
256
263
  workingLine: WorkingLineComponentConfig;
257
264
  selectorBorders: SelectorBordersComponentConfig;
258
265
  footer: FooterComponentConfig;
@@ -500,6 +507,7 @@ const defaultComponents: ComponentsConfig = {
500
507
  colorSource: "theme",
501
508
  styles: { framed: {}, "framed-copy-friendly": {}, compact: {}, labeled: {} },
502
509
  },
510
+ thinkingSteps: { enabled: false, mode: "tree" },
503
511
  workingLine: {
504
512
  enabled: false,
505
513
  turnSummary: true,
@@ -665,7 +673,10 @@ function parseContextThresholds(
665
673
  function parsePathDisplay(value: unknown): PathDisplayConfig {
666
674
  const defaults = defaultConfig.pathDisplay;
667
675
  if (!isRecord(value)) return { ...defaults };
668
- const mode = value.mode === "full" || value.mode === "basename" ? value.mode : defaults.mode;
676
+ const mode =
677
+ value.mode === "full" || value.mode === "basename" || value.mode === "repository"
678
+ ? value.mode
679
+ : defaults.mode;
669
680
  const rawDepth = value.depth;
670
681
  const depth =
671
682
  typeof rawDepth === "number" && Number.isFinite(rawDepth) && rawDepth >= 0
@@ -1188,6 +1199,7 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1188
1199
  const userMessages = recordValue(components.userMessages);
1189
1200
  const userMessageStyles = recordValue(userMessages.styles);
1190
1201
  const framed = recordValue(userMessageStyles.framed);
1202
+ const thinkingSteps = recordValue(components.thinkingSteps);
1191
1203
  const workingLine = recordValue(components.workingLine);
1192
1204
  const workingLineMessages = recordValue(workingLine.messages);
1193
1205
  const workingLineSegments = recordValue(workingLine.segments);
@@ -1295,6 +1307,15 @@ function resolveComponents(config: ConfigRecord): ComponentsConfig {
1295
1307
  labeled: {},
1296
1308
  },
1297
1309
  },
1310
+ thinkingSteps: {
1311
+ enabled: parseBoolean(thinkingSteps.enabled, defaultComponents.thinkingSteps.enabled),
1312
+ mode:
1313
+ thinkingSteps.mode === "rail" || thinkingSteps.mode === "tree"
1314
+ ? thinkingSteps.mode
1315
+ : thinkingSteps.mode === "streaming" || thinkingSteps.mode === "streaming-experimental"
1316
+ ? "streaming"
1317
+ : defaultComponents.thinkingSteps.mode,
1318
+ },
1298
1319
  workingLine: {
1299
1320
  enabled: parseBoolean(workingLine.enabled, defaultComponents.workingLine.enabled),
1300
1321
  turnSummary: parseBoolean(workingLine.turnSummary, defaultComponents.workingLine.turnSummary),
@@ -1734,6 +1755,17 @@ export function saveUserMessagesComponentPatch(
1734
1755
  );
1735
1756
  }
1736
1757
 
1758
+ export function saveThinkingStepsComponentPatch(
1759
+ patch: Partial<ThinkingStepsComponentConfig>,
1760
+ path = configPath,
1761
+ ): PolishedTuiConfig {
1762
+ return saveComponentsMutation((components) => {
1763
+ const component = components.thinkingSteps;
1764
+ if (patch.enabled !== undefined) component.enabled = patch.enabled;
1765
+ if (patch.mode !== undefined) component.mode = patch.mode;
1766
+ }, path);
1767
+ }
1768
+
1737
1769
  export function saveWorkingLineComponentPatch(
1738
1770
  patch: WorkingLineComponentPatch,
1739
1771
  path = configPath,
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { ZentuiConfig } from "./config";
3
3
  import { type FormatToken, parseFooterFormat } from "./footer-format";
4
+ import { buildSessionTokenLabel, formatCacheHitRate, formatContextPercentLabel } from "./format";
4
5
  import { EDITOR_ACCENT_FALLBACK, renderStyleForSourceOrFallback, safeThemeFg } from "./style";
5
6
 
6
7
  export type EditorMetadataValues = {
@@ -10,6 +11,11 @@ export type EditorMetadataValues = {
10
11
  provider: string;
11
12
  thinking: string;
12
13
  sessionName: string;
14
+ contextPercent?: number;
15
+ contextWindow?: number;
16
+ inputTokens?: number;
17
+ outputTokens?: number;
18
+ cacheHitRate?: number;
13
19
  };
14
20
 
15
21
  type RenderedTokens = {
@@ -18,6 +24,12 @@ type RenderedTokens = {
18
24
  hasNonEmptyDynamic: boolean;
19
25
  };
20
26
 
27
+ export type EditorMetadataZones = {
28
+ left: string;
29
+ middle: string;
30
+ right: string;
31
+ };
32
+
21
33
  const ESC = 0x1b;
22
34
  const BEL = 0x07;
23
35
  const CAN = 0x18;
@@ -164,7 +176,16 @@ function renderVariable(
164
176
  ? thinking
165
177
  : name === "session_name"
166
178
  ? values.sessionName
167
- : "";
179
+ : name === "context"
180
+ ? formatContextPercentLabel(values.contextPercent, values.contextWindow)
181
+ : name === "tokens"
182
+ ? buildSessionTokenLabel({
183
+ input: values.inputTokens ?? 0,
184
+ output: values.outputTokens ?? 0,
185
+ })
186
+ : name === "cache_hit"
187
+ ? formatCacheHitRate(values.cacheHitRate)
188
+ : "";
168
189
  const plain = sanitizeEditorMetadataText(raw);
169
190
  if (!plain) return { plain: "", styled: "" };
170
191
 
@@ -204,7 +225,7 @@ function renderVariable(
204
225
  ),
205
226
  };
206
227
  }
207
- if (name === "session_name") {
228
+ if (name === "session_name" || name === "context" || name === "tokens" || name === "cache_hit") {
208
229
  return { plain, styled: safeThemeFg(uiTheme, "border", plain) };
209
230
  }
210
231
  return { plain: "", styled: "" };
@@ -250,6 +271,41 @@ function renderTokens(
250
271
  return { styled, hasDynamic, hasNonEmptyDynamic };
251
272
  }
252
273
 
274
+ export function renderEditorMetadataFormatSplit(
275
+ format: string,
276
+ values: EditorMetadataValues,
277
+ uiTheme: Theme,
278
+ config: ZentuiConfig,
279
+ ): EditorMetadataZones {
280
+ const tokens = parseFooterFormat(sanitizeEditorMetadataText(format));
281
+ const fillIndices: number[] = [];
282
+ for (let index = 0; index < tokens.length; index++) {
283
+ if (tokens[index]?.kind === "fill") fillIndices.push(index);
284
+ }
285
+
286
+ const first = fillIndices[0];
287
+ const second = fillIndices[1];
288
+ if (first === undefined) {
289
+ return {
290
+ left: renderTokens(tokens, values, uiTheme, config).styled,
291
+ middle: "",
292
+ right: "",
293
+ };
294
+ }
295
+ if (second === undefined) {
296
+ return {
297
+ left: renderTokens(tokens.slice(0, first), values, uiTheme, config).styled,
298
+ middle: "",
299
+ right: renderTokens(tokens.slice(first + 1), values, uiTheme, config).styled,
300
+ };
301
+ }
302
+ return {
303
+ left: renderTokens(tokens.slice(0, first), values, uiTheme, config).styled,
304
+ middle: renderTokens(tokens.slice(first + 1, second), values, uiTheme, config).styled,
305
+ right: renderTokens(tokens.slice(second + 1), values, uiTheme, config).styled,
306
+ };
307
+ }
308
+
253
309
  export function renderEditorMetadataFormat(
254
310
  format: string,
255
311
  values: EditorMetadataValues,
@@ -35,6 +35,7 @@ import {
35
35
  formatRuntimeSegment,
36
36
  formatTimeLabel,
37
37
  formatUsernameHostLabel,
38
+ resolveContextUsage,
38
39
  } from "./format";
39
40
  import { resolveRuntimeSymbol } from "./icons";
40
41
  import type { LiveContextOverride } from "./live-context";
@@ -187,6 +188,7 @@ export function installFooter(
187
188
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
188
189
  setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
189
190
  getLiveContext?: () => LiveContextOverride | undefined;
191
+ getRepositoryRoot?: (cwd: string) => string | undefined;
190
192
  onDispose?: () => void;
191
193
  },
192
194
  ): void {
@@ -227,10 +229,13 @@ export function installFooter(
227
229
  );
228
230
  const colorSource = config.components.footer.colorSource;
229
231
  const iconMode = config.icons.mode;
232
+ const pathDisplay = config.components.footer.styles.starship.pathDisplay;
230
233
  const formattedCwd = sanitizeEditorMetadataText(
231
234
  formatCwdLabel(ctx.cwd, config.icons.cwd, {
232
- mode: config.components.footer.styles.starship.pathDisplay.mode,
233
- depth: config.components.footer.styles.starship.pathDisplay.depth,
235
+ mode: pathDisplay.mode,
236
+ depth: pathDisplay.depth,
237
+ repositoryRoot:
238
+ pathDisplay.mode === "repository" ? hooks.getRepositoryRoot?.(ctx.cwd) : undefined,
234
239
  }),
235
240
  );
236
241
  const branch = sanitizeEditorMetadataText(state.branch ?? "") || undefined;
@@ -282,14 +287,10 @@ export function installFooter(
282
287
  config.components.footer.styles.starship.gitBranch.maxLength,
283
288
  )
284
289
  : undefined;
285
- const contextUsage = ctx.getContextUsage();
286
- const liveContext = hooks.getLiveContext?.();
287
- const contextWindow = ctx.model?.contextWindow ?? contextUsage?.contextWindow;
288
- const useLiveContext =
289
- liveContext !== undefined && contextWindow !== undefined && contextWindow > 0;
290
- const contextPercent = useLiveContext
291
- ? (liveContext.tokens / contextWindow) * 100
292
- : contextUsage?.percent;
290
+ const { percent: contextPercent, contextWindow } = resolveContextUsage(
291
+ ctx,
292
+ hooks.getLiveContext?.(),
293
+ );
293
294
  const contextLabel = buildContextDisplayLabel({
294
295
  percent: contextPercent,
295
296
  contextWindow,
@@ -731,18 +732,7 @@ export function installFooter(
731
732
  if (reflowed) return frameRows(reflowed);
732
733
 
733
734
  const chunkBudget = compactChunkBudget(innerWidth);
734
- const compactCwdLabel = truncateToWidth(
735
- renderStyleForSource(
736
- theme,
737
- colorSource,
738
- config.colors.cwd,
739
- sanitizeEditorMetadataText(
740
- formatCwdLabel(ctx.cwd, config.icons.cwd, { mode: "basename", depth: 0 }),
741
- ),
742
- ),
743
- chunkBudget,
744
- "…",
745
- );
735
+ const compactCwdLabel = truncateToWidth(cwdLabel, chunkBudget, "…");
746
736
  const compactSessionNameLabel = truncateToWidth(
747
737
  sessionNameLabel,
748
738
  Math.max(1, chunkBudget - visibleWidth("in ")),
@@ -294,17 +294,24 @@ export function buildCacheWriteLabel(cacheWrite: number): string {
294
294
  return cacheWrite > 0 ? `W${formatCount(cacheWrite)}` : "";
295
295
  }
296
296
 
297
- export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = "󰆼"): string {
297
+ export function buildSessionTokenLabel(totals: Pick<UsageTotals, "input" | "output">): string {
298
298
  const parts: string[] = [];
299
299
  if (totals.input) parts.push(`↑${formatCount(totals.input)}`);
300
300
  if (totals.output) parts.push(`↓${formatCount(totals.output)}`);
301
+ return parts.length > 0 ? parts.join(" ") : "↑0 ↓0";
302
+ }
301
303
 
304
+ export function formatCacheHitRate(rate: number | undefined): string {
305
+ return `${rate !== undefined && Number.isFinite(rate) ? rate.toFixed(1) : "0.0"}%`;
306
+ }
307
+
308
+ export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = "󰆼"): string {
309
+ const tokenLabel = buildSessionTokenLabel(totals);
302
310
  const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
303
- if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
304
- const cacheHitRate = `${totals.latestCacheHitRate.toFixed(1)}%`;
305
- parts.push(cacheHitIcon ? `${cacheHitIcon} ${cacheHitRate}` : cacheHitRate);
306
- }
307
- return parts.length > 0 ? parts.join(" ") : "↑0 ↓0";
311
+ if (!hasCacheTokens || totals.latestCacheHitRate === undefined) return tokenLabel;
312
+
313
+ const cacheHitRate = formatCacheHitRate(totals.latestCacheHitRate);
314
+ return `${tokenLabel} ${cacheHitIcon ? `${cacheHitIcon} ` : ""}${cacheHitRate}`;
308
315
  }
309
316
 
310
317
  export function buildCostLabel(totals: UsageTotals): string {
@@ -372,10 +379,29 @@ export function buildContextDisplayLabel(options: {
372
379
  return text;
373
380
  }
374
381
 
375
- export function buildContextLabel(ctx: ExtensionContext): string {
382
+ export type ContextUsageSnapshot = {
383
+ percent: number | undefined;
384
+ contextWindow: number | undefined;
385
+ };
386
+
387
+ export function resolveContextUsage(
388
+ ctx: Pick<ExtensionContext, "model" | "getContextUsage">,
389
+ live?: { tokens: number },
390
+ ): ContextUsageSnapshot {
376
391
  const usage = ctx.getContextUsage();
377
392
  const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
378
- return formatContextPercentLabel(usage?.percent, contextWindow);
393
+ return {
394
+ percent:
395
+ live && contextWindow && contextWindow > 0
396
+ ? (live.tokens / contextWindow) * 100
397
+ : (usage?.percent ?? undefined),
398
+ contextWindow,
399
+ };
400
+ }
401
+
402
+ export function buildContextLabel(ctx: ExtensionContext): string {
403
+ const { percent, contextWindow } = resolveContextUsage(ctx);
404
+ return formatContextPercentLabel(percent, contextWindow);
379
405
  }
380
406
 
381
407
  export function formatRuntimeSegment(
@@ -417,9 +443,10 @@ export function formatPackageVersionSegment(
417
443
 
418
444
  export type FormatCwdOptions = {
419
445
  mode?: PathDisplayMode;
420
- /** Trailing directory components to keep in full mode. 0 = unlimited. */
446
+ /** Final path components to keep in full/repository mode. 0 = unlimited. */
421
447
  depth?: number;
422
448
  home?: string;
449
+ repositoryRoot?: string;
423
450
  };
424
451
 
425
452
  function normalizeDisplayPath(cwd: string): string {
@@ -462,8 +489,7 @@ function applyPathDepth(path: string, depth: number): string {
462
489
  export function formatCwdLabel(cwd: string, cwdIcon: string, options?: FormatCwdOptions): string {
463
490
  const mode = options?.mode ?? "basename";
464
491
  const normalized = normalizeDisplayPath(cwd);
465
- let pathText: string;
466
- if (mode === "full") {
492
+ const fullPath = (depth = options?.depth ?? 0) => {
467
493
  const home =
468
494
  options?.home ??
469
495
  (() => {
@@ -473,7 +499,25 @@ export function formatCwdLabel(cwd: string, cwdIcon: string, options?: FormatCwd
473
499
  return "";
474
500
  }
475
501
  })();
476
- pathText = applyPathDepth(toHomePath(normalized, home), options?.depth ?? 0);
502
+ return applyPathDepth(toHomePath(normalized, home), depth);
503
+ };
504
+ let pathText: string;
505
+ if (mode === "full") {
506
+ pathText = fullPath();
507
+ } else if (mode === "repository") {
508
+ const root = options?.repositoryRoot ? normalizeDisplayPath(options.repositoryRoot) : undefined;
509
+ if (!root) {
510
+ pathText = fullPath(0);
511
+ } else {
512
+ const rootPrefix = root === "/" ? "/" : `${root}/`;
513
+ if (normalized !== root && !normalized.startsWith(rootPrefix)) {
514
+ // Unsafe roots fail open to the existing unlimited full path.
515
+ pathText = fullPath(0);
516
+ } else {
517
+ const relative = normalized === root ? "." : normalized.slice(rootPrefix.length);
518
+ pathText = relative === "." ? relative : applyPathDepth(relative, options?.depth ?? 0);
519
+ }
520
+ }
477
521
  } else if (normalized === "/") {
478
522
  pathText = "/";
479
523
  } else {