pi-zentui 0.4.0 → 0.5.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
@@ -129,7 +129,7 @@ pi install git:github.com/lmilojevicc/pi-zentui
129
129
 
130
130
  User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` can patch color-source settings, UI feature toggles, built-in footer segment visibility, and active third-party status placements.
131
131
 
132
- The interactive `/zentui` menu is split into four sections. Use `Tab` and `Shift+Tab` to switch between `Coloring`, `Features`, `Built-in segments`, and `Extension segments`.
132
+ The interactive `/zentui` menu is split into five sections. Use `Tab` and `Shift+Tab` to switch between `Coloring`, `Features`, `Layout`, `Built-in segments`, and `Extension segments`.
133
133
 
134
134
  Useful slash-command shortcuts:
135
135
 
@@ -153,7 +153,13 @@ Default config values — copy this and change any value you want:
153
153
  {
154
154
  "projectRefreshIntervalMs": 30000,
155
155
  "footerFormat": "",
156
+ "contextStyle": "text",
157
+ "contextThresholds": {
158
+ "warning": 70,
159
+ "error": 90
160
+ },
156
161
  "icons": {
162
+ "mode": "auto",
157
163
  "cwd": "󰝰",
158
164
  "git": "",
159
165
  "ahead": "↑",
@@ -234,13 +240,15 @@ Default config values — copy this and change any value you want:
234
240
  }
235
241
  ```
236
242
 
237
- - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
238
- - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
239
- - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
243
+ - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b` / `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`). Short `#rgb` hex values expand to `#rrggbb`.
244
+ - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling. Values `1..4999` clamp up to `5000` (minimum 5s); invalid/non-finite values fall back to `30000`.
245
+ - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment.
246
+ - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
247
+ - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `icons.mode` is `auto` | `nerd` | `ascii` (default `auto`, same glyphs as nerd). ASCII mode swaps in plain fallbacks for statusline icons and runtime symbols — useful without a Nerd Font. Custom per-icon strings always win over mode defaults. Custom `icons.os` always wins; when left at the mode default, Zentui maps the OS icon by platform. `rail` sets the vertical glyph drawn as the left rail of the active editor frame and previous user messages when `copyFriendly` is disabled (default `│`; any single Unicode vertical or block glyph). `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
240
248
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
241
249
  - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. `copyFriendly` hides editor and previous-message rail glyphs so native terminal selection copies less chrome. All three can be changed from `/zentui` or direct slash-command arguments.
242
250
  - `footerSegments`: show or hide individual built-in footer segments (`cwd`, `gitBranch`, `gitStatus`, `gitCounts`, `runtime`, `sessionDuration`, `username`, `time`, `os`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
243
- - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below.
251
+ - `footerFormat`: optional Starship-style template string that fully controls the footer layout. When set, it overrides `footerSegments`. See [Footer Format Template](#footer-format-template) below. The `/zentui` **Layout** tab configures context style and icon mode; set or clear custom formats with `/zentui format`.
244
252
  - `extensionStatuses`: controls third-party statuses published by other Pi extensions through `ctx.ui.setStatus()`. `defaultPlacement` and each `placements` value can be `off`, `left`, `middle`, or `right`. The `Extension segments` tab in `/zentui` lists only statuses that are currently active.
245
253
  - The shown `editor*` values match the default `theme` source. Omit those keys to keep Zentui's source-aware defaults when switching between `theme` and `terminal`.
246
254
  - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
@@ -252,13 +260,13 @@ Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.
252
260
 
253
261
  ## Footer Format Template
254
262
 
255
- For full control, set a Starship-style `footerFormat` template string. It supports `$variable` and `${variable}` tokens plus a special `$fill` token that splits the line into left and right zones. When set, it overrides the built-in `footerSegments` layout; when empty or omitted, the segment layout above is used.
263
+ For full control, set a Starship-style `footerFormat` template string. It supports `$variable` and `${variable}` tokens, a special `$fill` token that splits the line into left and right zones, and conditional groups `( ... )` that drop entirely when every nested variable is empty. When set, it overrides the built-in `footerSegments` layout; when empty or omitted, the segment layout above is used.
256
264
 
257
265
  A second `$fill` creates a **centered middle zone** — content between the two fills is true-centered (`floor((gap - middle) / 2)`), just like third-party statuses placed `middle`.
258
266
 
259
267
  ```json
260
268
  {
261
- "footerFormat": "$os $username $cwd on branch $git_branch$git_status using $runtime $fill $context | $tokens | $cost $time"
269
+ "footerFormat": "$os $username $cwd( on $git_branch)( $git_status)( via $runtime)$fill($context)($sep$tokens)($sep$cost)($sep$time)"
262
270
  }
263
271
  ```
264
272
 
@@ -272,20 +280,22 @@ Center the branch between directory and cost:
272
280
 
273
281
  ### Variables
274
282
 
275
- | Token | Aliases | Renders |
276
- | ------------------- | ------------ | ------------------------- |
277
- | `$cwd` | `$directory` | current directory |
278
- | `$git_branch` | `$branch` | git branch with icon |
279
- | `$git_status` | `$status` | `[!?↑]` status block |
280
- | `$runtime` | | runtime icon + version |
281
- | `$session_duration` | `$duration` | session running time |
282
- | `$username` | | `user@host` |
283
- | `$os` | | operating-system icon |
284
- | `$time` | | current time `HH:MM` |
285
- | `$context` | | context usage |
286
- | `$tokens` | | input/output token counts |
287
- | `$cost` | | session cost |
288
- | `$fill` || special: splits zones |
283
+ | Token | Aliases | Renders |
284
+ | ------------------- | ------------ | ------------------------------------------------ |
285
+ | `$cwd` | `$directory` | current directory |
286
+ | `$git_branch` | `$branch` | git branch with icon |
287
+ | `$git_status` | `$status` | `[!?↑]` status block |
288
+ | `$git_state` | `$state` | `REBASING` / `MERGING` / … (optional `n/m`) |
289
+ | `$runtime` | | runtime icon + version |
290
+ | `$session_duration` | `$duration` | session running time |
291
+ | `$username` | | `user@host` |
292
+ | `$os` | | operating-system icon |
293
+ | `$time` | | current time `HH:MM` |
294
+ | `$context` | | context usage (text and/or gauge via config) |
295
+ | `$tokens` | | input/output token counts |
296
+ | `$cost` | | session cost |
297
+ | `$sep` | `$separator` | themed ` | ` using `colors.separator` |
298
+ | `$fill` | — | special: splits zones |
289
299
 
290
300
  ### `$fill` behavior
291
301
 
@@ -298,13 +308,14 @@ Center the branch between directory and cost:
298
308
 
299
309
  - Literal text (`on branch`, `using`, `\|`, spaces) is rendered verbatim — you control all spacing.
300
310
  - Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
311
+ - Conditional groups: wrap optional pieces in parentheses, e.g. `$cwd( on $git_branch)($git_status)$fill($context)`. If every `$var` inside a group is empty, the whole group (including its literals) is dropped.
301
312
  - Unknown `$variables` render empty.
302
313
  - Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
303
314
 
304
315
  ## Requirements
305
316
 
306
317
  - [Pi](https://pi.dev) coding agent 0.79 or newer
307
- - A [Nerd Font](https://www.nerdfonts.com/) for icons
318
+ - A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
308
319
 
309
320
  ## Development
310
321
 
@@ -1,10 +1,27 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import {
5
+ ICON_GLYPH_KEYS,
6
+ type IconGlyphs,
7
+ type IconMode,
8
+ NERD_DEFAULT_ICONS,
9
+ normalizeIconMode,
10
+ type ResolvedIcons,
11
+ resolveConfiguredIcons,
12
+ } from "./icons";
4
13
  import { isSupportedColorSpec } from "./style";
5
14
 
6
15
  export type ColorSpec = string;
7
16
  export type ColorSource = "theme" | "terminal";
17
+ export type { IconMode } from "./icons";
18
+
19
+ export type ContextStyle = "text" | "gauge" | "text+gauge";
20
+
21
+ export type ContextThresholds = {
22
+ warning: number;
23
+ error: number;
24
+ };
8
25
 
9
26
  export type ColorSourcesConfig = {
10
27
  starship: ColorSource;
@@ -50,27 +67,9 @@ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
50
67
  export type PolishedTuiConfig = {
51
68
  projectRefreshIntervalMs: number;
52
69
  footerFormat: string;
53
- icons: {
54
- cwd: string;
55
- git: string;
56
- ahead: string;
57
- behind: string;
58
- diverged: string;
59
- conflicted: string;
60
- untracked: string;
61
- stashed: string;
62
- modified: string;
63
- staged: string;
64
- renamed: string;
65
- deleted: string;
66
- typechanged: string;
67
- cacheHit: string;
68
- editorPrompt: string;
69
- rail: string;
70
- username: string;
71
- time: string;
72
- os: string;
73
- };
70
+ contextStyle: ContextStyle;
71
+ contextThresholds: ContextThresholds;
72
+ icons: ResolvedIcons;
74
73
  colors: {
75
74
  cwd: ColorSpec;
76
75
  gitBranch: ColorSpec;
@@ -113,6 +112,7 @@ export const FOOTER_FORMAT_VARIABLES = [
113
112
  "cwd",
114
113
  "git_branch",
115
114
  "git_status",
115
+ "git_state",
116
116
  "runtime",
117
117
  "session_duration",
118
118
  "username",
@@ -121,6 +121,7 @@ export const FOOTER_FORMAT_VARIABLES = [
121
121
  "context",
122
122
  "tokens",
123
123
  "cost",
124
+ "sep",
124
125
  ] as const;
125
126
 
126
127
  /**
@@ -131,7 +132,9 @@ export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
131
132
  directory: "cwd",
132
133
  branch: "git_branch",
133
134
  status: "git_status",
135
+ state: "git_state",
134
136
  duration: "session_duration",
137
+ separator: "sep",
135
138
  };
136
139
 
137
140
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -139,26 +142,11 @@ export const configPath = join(getAgentDir(), "zentui.json");
139
142
  export const defaultConfig: PolishedTuiConfig = {
140
143
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
141
144
  footerFormat: "",
145
+ contextStyle: "text",
146
+ contextThresholds: { warning: 70, error: 90 },
142
147
  icons: {
143
- cwd: "󰝰",
144
- git: "",
145
- ahead: "↑",
146
- behind: "↓",
147
- diverged: "⇕",
148
- conflicted: "=",
149
- untracked: "?",
150
- stashed: "$",
151
- modified: "!",
152
- staged: "+",
153
- renamed: "»",
154
- deleted: "✘",
155
- typechanged: "T",
156
- cacheHit: "󰆼",
157
- editorPrompt: "",
158
- rail: "│",
159
- username: "",
160
- time: "",
161
- os: "",
148
+ mode: "auto",
149
+ ...NERD_DEFAULT_ICONS,
162
150
  },
163
151
  colors: {
164
152
  cwd: "bold cyan",
@@ -208,27 +196,6 @@ export const defaultConfig: PolishedTuiConfig = {
208
196
  },
209
197
  };
210
198
 
211
- const iconKeys = [
212
- "cwd",
213
- "git",
214
- "ahead",
215
- "behind",
216
- "diverged",
217
- "conflicted",
218
- "untracked",
219
- "stashed",
220
- "modified",
221
- "staged",
222
- "renamed",
223
- "deleted",
224
- "typechanged",
225
- "cacheHit",
226
- "editorPrompt",
227
- "username",
228
- "time",
229
- "os",
230
- ] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
231
-
232
199
  type ConfigRecord = Record<string, unknown>;
233
200
 
234
201
  function isRecord(value: unknown): value is ConfigRecord {
@@ -238,17 +205,43 @@ function isRecord(value: unknown): value is ConfigRecord {
238
205
  function parseProjectRefreshIntervalMs(value: unknown): number {
239
206
  if (value === 0) return 0;
240
207
  if (typeof value !== "number" || !Number.isFinite(value)) {
241
- return defaultConfig.projectRefreshIntervalMs;
208
+ return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
242
209
  }
243
210
 
244
211
  const interval = Math.round(value);
245
- return interval >= MIN_PROJECT_REFRESH_INTERVAL_MS
246
- ? interval
247
- : defaultConfig.projectRefreshIntervalMs;
212
+ if (interval <= 0) return 0;
213
+ return Math.max(MIN_PROJECT_REFRESH_INTERVAL_MS, interval);
248
214
  }
249
215
 
250
- function railValue(value: unknown): string {
251
- return typeof value === "string" && value.trim().length > 0 ? value : defaultConfig.icons.rail;
216
+ function clampPercent(value: number): number {
217
+ return Math.max(0, Math.min(100, value));
218
+ }
219
+
220
+ function parseContextStyle(value: unknown): ContextStyle {
221
+ if (value === "text" || value === "gauge" || value === "text+gauge") return value;
222
+ return defaultConfig.contextStyle;
223
+ }
224
+
225
+ function parseContextThresholds(value: unknown): ContextThresholds {
226
+ const defaults = defaultConfig.contextThresholds;
227
+ if (!isRecord(value)) return { ...defaults };
228
+
229
+ const warningRaw = value.warning;
230
+ const errorRaw = value.error;
231
+ let warning =
232
+ typeof warningRaw === "number" && Number.isFinite(warningRaw)
233
+ ? clampPercent(Math.round(warningRaw))
234
+ : defaults.warning;
235
+ let error =
236
+ typeof errorRaw === "number" && Number.isFinite(errorRaw)
237
+ ? clampPercent(Math.round(errorRaw))
238
+ : defaults.error;
239
+ if (error < warning) {
240
+ const swapped = warning;
241
+ warning = error;
242
+ error = swapped;
243
+ }
244
+ return { warning, error };
252
245
  }
253
246
 
254
247
  function stringValue(record: Record<string, unknown>, key: string): string | undefined {
@@ -292,13 +285,13 @@ function definedColors(
292
285
  ) as Partial<PolishedTuiConfig["colors"]>;
293
286
  }
294
287
 
295
- function normalizeIcons(record: Record<string, unknown>): Partial<PolishedTuiConfig["icons"]> {
288
+ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGlyphs> {
296
289
  return Object.fromEntries(
297
- iconKeys.flatMap((key) => {
290
+ ICON_GLYPH_KEYS.flatMap((key) => {
298
291
  const value = stringValue(record, key);
299
292
  return value === undefined ? [] : [[key, value]];
300
293
  }),
301
- ) as Partial<PolishedTuiConfig["icons"]>;
294
+ ) as Partial<IconGlyphs>;
302
295
  }
303
296
 
304
297
  function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
@@ -473,7 +466,8 @@ export function ensureConfigExists(): void {
473
466
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
474
467
  const config = isRecord(parsed) ? parsed : {};
475
468
  const iconsRecord = isRecord(config.icons) ? (config.icons as Record<string, unknown>) : {};
476
- const icons = normalizeIcons(iconsRecord);
469
+ const iconMode = normalizeIconMode(iconsRecord.mode);
470
+ const iconOverrides = normalizeIconOverrides(iconsRecord);
477
471
  const colors = isRecord(config.colors)
478
472
  ? normalizeColors(config.colors as Record<string, unknown>)
479
473
  : {};
@@ -492,11 +486,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
492
486
  return {
493
487
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
494
488
  footerFormat: stringValue(config, "footerFormat") ?? "",
495
- icons: {
496
- ...defaultConfig.icons,
497
- ...icons,
498
- rail: railValue(iconsRecord.rail),
499
- },
489
+ contextStyle: parseContextStyle(config.contextStyle),
490
+ contextThresholds: parseContextThresholds(config.contextThresholds),
491
+ icons: resolveConfiguredIcons(iconMode, iconOverrides),
500
492
  colors: {
501
493
  ...defaultConfig.colors,
502
494
  ...colors,
@@ -590,6 +582,40 @@ export function saveFooterFormatPatch(value: string, path = configPath): Polishe
590
582
  return mergeConfig(record);
591
583
  }
592
584
 
585
+ export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
586
+ const record = readConfigRecord(path);
587
+ const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
588
+ record.icons = {
589
+ ...existing,
590
+ mode: normalizeIconMode(mode),
591
+ };
592
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
593
+ return mergeConfig(record);
594
+ }
595
+
596
+ export function saveContextStylePatch(style: ContextStyle, path = configPath): PolishedTuiConfig {
597
+ const record = readConfigRecord(path);
598
+ record.contextStyle = parseContextStyle(style);
599
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
600
+ return mergeConfig(record);
601
+ }
602
+
603
+ export function saveContextThresholdsPatch(
604
+ thresholds: Partial<ContextThresholds>,
605
+ path = configPath,
606
+ ): PolishedTuiConfig {
607
+ const record = readConfigRecord(path);
608
+ const existing = isRecord(record.contextThresholds)
609
+ ? { ...(record.contextThresholds as Record<string, unknown>) }
610
+ : {};
611
+ record.contextThresholds = {
612
+ ...existing,
613
+ ...thresholds,
614
+ };
615
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
616
+ return mergeConfig(record);
617
+ }
618
+
593
619
  export function saveExtensionStatusPlacement(
594
620
  key: string,
595
621
  placement: ExtensionStatusPlacement,
@@ -2,46 +2,98 @@
2
2
  * Starship-style footer format string parser and renderer.
3
3
  *
4
4
  * Pure module (no TUI/config imports) so it is fully unit-testable.
5
+ *
6
+ * Supports conditional groups: `( ... )` is dropped when every nested
7
+ * variable (and nested group) renders empty.
5
8
  */
6
9
 
7
10
  export type FormatToken =
8
11
  | { kind: "text"; value: string }
9
12
  | { kind: "var"; name: string }
10
- | { kind: "fill" };
13
+ | { kind: "fill" }
14
+ | { kind: "group"; tokens: FormatToken[] };
11
15
 
12
16
  const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
13
17
 
14
18
  /**
15
- * Tokenize a format string into text/var/fill tokens.
19
+ * Tokenize a format string into text/var/fill/group tokens.
16
20
  *
17
21
  * `$name` and `${name}` both produce a variable token. A variable named
18
- * `fill` becomes a fill token instead. Text between/around variables is
19
- * preserved exactly (including spaces). Empty input produces an empty array.
22
+ * `fill` becomes a fill token instead. Parentheses form conditional groups
23
+ * that drop entirely when all nested vars are empty.
20
24
  */
21
25
  export function parseFooterFormat(format: string): FormatToken[] {
22
26
  if (!format) return [];
27
+ return parseTokenSlice(format, 0, format.length, true).tokens;
28
+ }
23
29
 
30
+ function parseTokenSlice(
31
+ format: string,
32
+ start: number,
33
+ end: number,
34
+ topLevel = false,
35
+ ): { tokens: FormatToken[]; nextIndex: number } {
24
36
  const tokens: FormatToken[] = [];
25
- let lastIndex = 0;
37
+ let index = start;
38
+ let textStart = start;
39
+
40
+ const flushText = (until: number) => {
41
+ if (until > textStart) {
42
+ tokens.push({ kind: "text", value: format.slice(textStart, until) });
43
+ }
44
+ };
26
45
 
27
- for (const match of format.matchAll(TOKEN_REGEX)) {
28
- if (match.index !== undefined && match.index > lastIndex) {
29
- tokens.push({ kind: "text", value: format.slice(lastIndex, match.index) });
46
+ while (index < end) {
47
+ const ch = format[index];
48
+
49
+ if (ch === "(") {
50
+ flushText(index);
51
+ const nested = parseTokenSlice(format, index + 1, end, false);
52
+ tokens.push({ kind: "group", tokens: nested.tokens });
53
+ index = nested.nextIndex;
54
+ textStart = index;
55
+ continue;
30
56
  }
31
- const name = match[1] ?? match[2];
32
- if (name === "fill") {
33
- tokens.push({ kind: "fill" });
34
- } else {
35
- tokens.push({ kind: "var", name });
57
+
58
+ if (ch === ")") {
59
+ // Nested groups close on `)`. Unmatched top-level `)` is literal text so
60
+ // trailing tokens like `$cwd) $tokens` are not discarded.
61
+ if (topLevel) {
62
+ index += 1;
63
+ continue;
64
+ }
65
+ flushText(index);
66
+ return { tokens, nextIndex: index + 1 };
36
67
  }
37
- lastIndex = (match.index ?? 0) + match[0].length;
38
- }
39
68
 
40
- if (lastIndex < format.length) {
41
- tokens.push({ kind: "text", value: format.slice(lastIndex) });
69
+ if (ch === "$") {
70
+ TOKEN_REGEX.lastIndex = index;
71
+ const match = TOKEN_REGEX.exec(format);
72
+ if (match && match.index === index && match.index < end) {
73
+ const full = match[0];
74
+ const matchEnd = match.index + full.length;
75
+ if (matchEnd > end) {
76
+ index += 1;
77
+ continue;
78
+ }
79
+ flushText(index);
80
+ const name = match[1] ?? match[2] ?? "";
81
+ if (name === "fill") {
82
+ tokens.push({ kind: "fill" });
83
+ } else {
84
+ tokens.push({ kind: "var", name });
85
+ }
86
+ index = matchEnd;
87
+ textStart = index;
88
+ continue;
89
+ }
90
+ }
91
+
92
+ index += 1;
42
93
  }
43
94
 
44
- return tokens;
95
+ flushText(end);
96
+ return { tokens, nextIndex: end };
45
97
  }
46
98
 
47
99
  /**
@@ -53,6 +105,7 @@ export function parseFooterFormat(format: string): FormatToken[] {
53
105
  * (centered by the caller via the existing middle-zone logic), after the
54
106
  * second → `right`.
55
107
  * - Additional fills beyond the first two are ignored.
108
+ * - `$fill` inside a group is ignored (renders empty).
56
109
  *
57
110
  * Text tokens contribute their `value` verbatim (unstyled/plain); var tokens
58
111
  * contribute `renderVariable(name)` (already styled by caller). No automatic
@@ -62,10 +115,7 @@ export function renderFormatSplit(
62
115
  tokens: FormatToken[],
63
116
  renderVariable: (name: string) => string,
64
117
  ): { left: string; middle: string; right: string } {
65
- const fillIndices: number[] = [];
66
- for (let index = 0; index < tokens.length; index++) {
67
- if (tokens[index]?.kind === "fill") fillIndices.push(index);
68
- }
118
+ const fillIndices = findTopLevelFillIndices(tokens);
69
119
 
70
120
  if (fillIndices.length === 0) {
71
121
  return {
@@ -101,6 +151,14 @@ export function renderFormatSplit(
101
151
  };
102
152
  }
103
153
 
154
+ function findTopLevelFillIndices(tokens: FormatToken[]): number[] {
155
+ const fillIndices: number[] = [];
156
+ for (let index = 0; index < tokens.length; index++) {
157
+ if (tokens[index]?.kind === "fill") fillIndices.push(index);
158
+ }
159
+ return fillIndices;
160
+ }
161
+
104
162
  function renderTokenSlice(
105
163
  tokens: FormatToken[],
106
164
  start: number,
@@ -111,11 +169,111 @@ function renderTokenSlice(
111
169
  for (let i = start; i < end; i++) {
112
170
  const token = tokens[i];
113
171
  if (!token) continue;
114
- if (token.kind === "text") {
115
- result += token.value;
116
- } else if (token.kind === "var") {
117
- result += renderVariable(token.name);
172
+ result += renderToken(token, renderVariable);
173
+ }
174
+ return result;
175
+ }
176
+
177
+ function renderToken(token: FormatToken, renderVariable: (name: string) => string): string {
178
+ if (token.kind === "text") return token.value;
179
+ if (token.kind === "var") return renderVariable(token.name);
180
+ if (token.kind === "fill") return "";
181
+ // group
182
+ const rendered = token.tokens.map((child) => renderToken(child, renderVariable)).join("");
183
+ if (isGroupEmpty(token, renderVariable)) return "";
184
+ return rendered;
185
+ }
186
+
187
+ /**
188
+ * Separator vars only style gaps between content; they must not keep a group
189
+ * alive when every real content var is empty (e.g. `($sep$tokens)` drops if
190
+ * tokens is empty).
191
+ */
192
+ const NON_CONTENT_VARS = new Set(["sep", "separator"]);
193
+
194
+ /**
195
+ * A group is empty iff every content var leaf is empty and every nested group
196
+ * is empty. Text-only groups (no vars) always show. `$sep` / `$separator` are
197
+ * ignored for emptiness so orphan themed pipes do not force a group to render.
198
+ */
199
+ function isGroupEmpty(
200
+ group: FormatToken & { kind: "group" },
201
+ renderVariable: (name: string) => string,
202
+ ): boolean {
203
+ let sawContentVarOrGroup = false;
204
+ for (const child of group.tokens) {
205
+ if (child.kind === "var") {
206
+ if (NON_CONTENT_VARS.has(child.name)) continue;
207
+ sawContentVarOrGroup = true;
208
+ if (renderVariable(child.name) !== "") return false;
209
+ } else if (child.kind === "group") {
210
+ sawContentVarOrGroup = true;
211
+ if (!isGroupEmpty(child, renderVariable)) return false;
212
+ }
213
+ }
214
+ // Text-only groups (or groups with only $sep) are shown only when no content vars.
215
+ // Groups that only contain $sep still count as empty so they drop.
216
+ return sawContentVarOrGroup || groupOnlyNonContentVars(group);
217
+ }
218
+
219
+ function groupOnlyNonContentVars(group: FormatToken & { kind: "group" }): boolean {
220
+ let sawSep = false;
221
+ for (const child of group.tokens) {
222
+ if (child.kind === "text") {
223
+ if (child.value.trim() !== "") return false;
224
+ continue;
225
+ }
226
+ if (child.kind === "var") {
227
+ if (!NON_CONTENT_VARS.has(child.name)) return false;
228
+ sawSep = true;
229
+ continue;
118
230
  }
231
+ if (child.kind === "group") return false;
232
+ if (child.kind === "fill") continue;
119
233
  }
234
+ return sawSep;
235
+ }
236
+
237
+ /** One optional SGR sequence (`\x1b[…m`). */
238
+ const ANSI_ONE_SRC = "\u001b\\[[0-9;]*m";
239
+
240
+ /**
241
+ * One ` | ` separator unit, plain or with a single ANSI wrapper on either side
242
+ * of the spaces/pipe (matches `renderStyle(..., " | ")` output).
243
+ */
244
+ const SEP_UNIT_SRC = `(?:${ANSI_ONE_SRC})?\\s+\\|\\s+(?:${ANSI_ONE_SRC})?`;
245
+
246
+ /**
247
+ * Join non-empty parts with a separator (segment-mode style).
248
+ * Useful when building right-side metrics without orphan pipes.
249
+ */
250
+ export function joinNonEmpty(parts: string[], separator: string): string {
251
+ return parts.filter(Boolean).join(separator);
252
+ }
253
+
254
+ /**
255
+ * Tidy a rendered format left/middle/right slice:
256
+ * - collapse repeated pipe separators (plain or simple ANSI-wrapped) into one
257
+ * - strip leading/trailing pipe separators
258
+ * - strip leading/trailing whitespace
259
+ * - drop slices that are only ANSI / whitespace after cleanup
260
+ */
261
+ export function stripOrphanSeparators(rendered: string): string {
262
+ if (!rendered) return rendered;
263
+
264
+ // Collapse consecutive separator units, keeping the first (preserves themed color).
265
+ const consecutive = new RegExp(`(${SEP_UNIT_SRC})(?:${SEP_UNIT_SRC})+`, "g");
266
+ let result = rendered.replace(consecutive, "$1");
267
+
268
+ // Strip leading / trailing separator units.
269
+ result = result.replace(new RegExp(`^(?:${SEP_UNIT_SRC})+`), "");
270
+ result = result.replace(new RegExp(`(?:${SEP_UNIT_SRC})+$`), "");
271
+
272
+ // Strip leading / trailing plain whitespace left by empty groups.
273
+ result = result.replace(/^\s+/, "").replace(/\s+$/, "");
274
+
275
+ // Pure ANSI (or empty) leftovers are not useful content.
276
+ if (result.replace(new RegExp(ANSI_ONE_SRC, "g"), "").trim() === "") return "";
277
+
120
278
  return result;
121
279
  }