pi-zentui 0.4.0 → 0.6.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
@@ -17,7 +17,7 @@ Zentui brings two popular aesthetics to Pi:
17
17
 
18
18
  ### Footer (Starship-inspired)
19
19
 
20
- - `󰝰 dirname` — current directory with icon
20
+ - `󰝰 dirname` — current directory with icon (`basename` by default; optional `full` path with directory depth via `pathDisplay`)
21
21
  - `on branch` — git branch with icon
22
22
  - `[!?↑]` — git status indicators (modified, untracked, ahead/behind, stashed, etc.)
23
23
  - `via v5.5.0` — runtime detection with version and Starship-style Nerd Font runtime/language modules
@@ -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,17 @@ 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
+ },
161
+ "pathDisplay": {
162
+ "mode": "basename",
163
+ "depth": 0
164
+ },
156
165
  "icons": {
166
+ "mode": "auto",
157
167
  "cwd": "󰝰",
158
168
  "git": "",
159
169
  "ahead": "↑",
@@ -234,13 +244,16 @@ Default config values — copy this and change any value you want:
234
244
  }
235
245
  ```
236
246
 
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.
247
+ - 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`.
248
+ - `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`.
249
+ - `contextStyle`: `text` (default), `gauge`, or `text+gauge` for the context segment.
250
+ - `contextThresholds`: `{ warning, error }` percentages (default `70` / `90`) that select contextNormal / contextWarning / contextError colors.
251
+ - `pathDisplay`: controls how the cwd/`$cwd` path is shown. `mode` is `basename` (default, last segment only) or `full` (path with home contracted to `~`). In `full` mode, `depth` keeps only the last N trailing directories (`0` = entire path after `~`, max `5`); when parents are dropped the path is prefixed with `…/` (Starship-style). The `/zentui` **Layout** tab cycles path mode and path depth (`0`–`5`; depth is ignored for basename). Example: `~/Projects/foo/bar` with `depth: 2` → `…/foo/bar`.
252
+ - `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
253
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
241
254
  - `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
255
  - `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.
256
+ - `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, path display mode/depth, and icon mode; set or clear custom formats with `/zentui format`.
244
257
  - `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
258
  - 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
259
  - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
@@ -252,13 +265,13 @@ Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.
252
265
 
253
266
  ## Footer Format Template
254
267
 
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.
268
+ 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
269
 
257
270
  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
271
 
259
272
  ```json
260
273
  {
261
- "footerFormat": "$os $username $cwd on branch $git_branch$git_status using $runtime $fill $context | $tokens | $cost $time"
274
+ "footerFormat": "$os $username $cwd( on $git_branch)( $git_status)( via $runtime)$fill($context)($sep$tokens)($sep$cost)($sep$time)"
262
275
  }
263
276
  ```
264
277
 
@@ -272,20 +285,22 @@ Center the branch between directory and cost:
272
285
 
273
286
  ### Variables
274
287
 
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 |
288
+ | Token | Aliases | Renders |
289
+ | ------------------- | ------------ | ------------------------------------------------ |
290
+ | `$cwd` | `$directory` | current directory |
291
+ | `$git_branch` | `$branch` | git branch with icon |
292
+ | `$git_status` | `$status` | `[!?↑]` status block |
293
+ | `$git_state` | `$state` | `REBASING` / `MERGING` / … (optional `n/m`) |
294
+ | `$runtime` | | runtime icon + version |
295
+ | `$session_duration` | `$duration` | session running time |
296
+ | `$username` | | `user@host` |
297
+ | `$os` | | operating-system icon |
298
+ | `$time` | | current time `HH:MM` |
299
+ | `$context` | | context usage (text and/or gauge via config) |
300
+ | `$tokens` | | input/output token counts |
301
+ | `$cost` | | session cost |
302
+ | `$sep` | `$separator` | themed ` | ` using `colors.separator` |
303
+ | `$fill` | — | special: splits zones |
289
304
 
290
305
  ### `$fill` behavior
291
306
 
@@ -298,13 +313,14 @@ Center the branch between directory and cost:
298
313
 
299
314
  - Literal text (`on branch`, `using`, `\|`, spaces) is rendered verbatim — you control all spacing.
300
315
  - Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
316
+ - 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
317
  - Unknown `$variables` render empty.
302
318
  - Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
303
319
 
304
320
  ## Requirements
305
321
 
306
322
  - [Pi](https://pi.dev) coding agent 0.79 or newer
307
- - A [Nerd Font](https://www.nerdfonts.com/) for icons
323
+ - A [Nerd Font](https://www.nerdfonts.com/) for icons (or set `icons.mode` to `"ascii"`)
308
324
 
309
325
  ## Development
310
326
 
@@ -1,10 +1,35 @@
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
+ };
25
+
26
+ export type PathDisplayMode = "basename" | "full";
27
+
28
+ export type PathDisplayConfig = {
29
+ mode: PathDisplayMode;
30
+ /** Trailing directories to show in full mode. 0 = unlimited; clamped to 0..5. */
31
+ depth: number;
32
+ };
8
33
 
9
34
  export type ColorSourcesConfig = {
10
35
  starship: ColorSource;
@@ -50,27 +75,10 @@ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
50
75
  export type PolishedTuiConfig = {
51
76
  projectRefreshIntervalMs: number;
52
77
  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
- };
78
+ contextStyle: ContextStyle;
79
+ contextThresholds: ContextThresholds;
80
+ pathDisplay: PathDisplayConfig;
81
+ icons: ResolvedIcons;
74
82
  colors: {
75
83
  cwd: ColorSpec;
76
84
  gitBranch: ColorSpec;
@@ -113,6 +121,7 @@ export const FOOTER_FORMAT_VARIABLES = [
113
121
  "cwd",
114
122
  "git_branch",
115
123
  "git_status",
124
+ "git_state",
116
125
  "runtime",
117
126
  "session_duration",
118
127
  "username",
@@ -121,6 +130,7 @@ export const FOOTER_FORMAT_VARIABLES = [
121
130
  "context",
122
131
  "tokens",
123
132
  "cost",
133
+ "sep",
124
134
  ] as const;
125
135
 
126
136
  /**
@@ -131,7 +141,9 @@ export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
131
141
  directory: "cwd",
132
142
  branch: "git_branch",
133
143
  status: "git_status",
144
+ state: "git_state",
134
145
  duration: "session_duration",
146
+ separator: "sep",
135
147
  };
136
148
 
137
149
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -139,26 +151,12 @@ export const configPath = join(getAgentDir(), "zentui.json");
139
151
  export const defaultConfig: PolishedTuiConfig = {
140
152
  projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
141
153
  footerFormat: "",
154
+ contextStyle: "text",
155
+ contextThresholds: { warning: 70, error: 90 },
156
+ pathDisplay: { mode: "basename", depth: 0 },
142
157
  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: "",
158
+ mode: "auto",
159
+ ...NERD_DEFAULT_ICONS,
162
160
  },
163
161
  colors: {
164
162
  cwd: "bold cyan",
@@ -208,27 +206,6 @@ export const defaultConfig: PolishedTuiConfig = {
208
206
  },
209
207
  };
210
208
 
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
209
  type ConfigRecord = Record<string, unknown>;
233
210
 
234
211
  function isRecord(value: unknown): value is ConfigRecord {
@@ -238,17 +215,55 @@ function isRecord(value: unknown): value is ConfigRecord {
238
215
  function parseProjectRefreshIntervalMs(value: unknown): number {
239
216
  if (value === 0) return 0;
240
217
  if (typeof value !== "number" || !Number.isFinite(value)) {
241
- return defaultConfig.projectRefreshIntervalMs;
218
+ return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
242
219
  }
243
220
 
244
221
  const interval = Math.round(value);
245
- return interval >= MIN_PROJECT_REFRESH_INTERVAL_MS
246
- ? interval
247
- : defaultConfig.projectRefreshIntervalMs;
222
+ if (interval <= 0) return 0;
223
+ return Math.max(MIN_PROJECT_REFRESH_INTERVAL_MS, interval);
248
224
  }
249
225
 
250
- function railValue(value: unknown): string {
251
- return typeof value === "string" && value.trim().length > 0 ? value : defaultConfig.icons.rail;
226
+ function clampPercent(value: number): number {
227
+ return Math.max(0, Math.min(100, value));
228
+ }
229
+
230
+ function parseContextStyle(value: unknown): ContextStyle {
231
+ if (value === "text" || value === "gauge" || value === "text+gauge") return value;
232
+ return defaultConfig.contextStyle;
233
+ }
234
+
235
+ function parseContextThresholds(value: unknown): ContextThresholds {
236
+ const defaults = defaultConfig.contextThresholds;
237
+ if (!isRecord(value)) return { ...defaults };
238
+
239
+ const warningRaw = value.warning;
240
+ const errorRaw = value.error;
241
+ let warning =
242
+ typeof warningRaw === "number" && Number.isFinite(warningRaw)
243
+ ? clampPercent(Math.round(warningRaw))
244
+ : defaults.warning;
245
+ let error =
246
+ typeof errorRaw === "number" && Number.isFinite(errorRaw)
247
+ ? clampPercent(Math.round(errorRaw))
248
+ : defaults.error;
249
+ if (error < warning) {
250
+ const swapped = warning;
251
+ warning = error;
252
+ error = swapped;
253
+ }
254
+ return { warning, error };
255
+ }
256
+
257
+ function parsePathDisplay(value: unknown): PathDisplayConfig {
258
+ const defaults = defaultConfig.pathDisplay;
259
+ if (!isRecord(value)) return { ...defaults };
260
+ const mode = value.mode === "full" || value.mode === "basename" ? value.mode : defaults.mode;
261
+ const rawDepth = value.depth;
262
+ const depth =
263
+ typeof rawDepth === "number" && Number.isFinite(rawDepth) && rawDepth >= 0
264
+ ? Math.min(5, Math.floor(rawDepth))
265
+ : defaults.depth;
266
+ return { mode, depth };
252
267
  }
253
268
 
254
269
  function stringValue(record: Record<string, unknown>, key: string): string | undefined {
@@ -292,13 +307,13 @@ function definedColors(
292
307
  ) as Partial<PolishedTuiConfig["colors"]>;
293
308
  }
294
309
 
295
- function normalizeIcons(record: Record<string, unknown>): Partial<PolishedTuiConfig["icons"]> {
310
+ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGlyphs> {
296
311
  return Object.fromEntries(
297
- iconKeys.flatMap((key) => {
312
+ ICON_GLYPH_KEYS.flatMap((key) => {
298
313
  const value = stringValue(record, key);
299
314
  return value === undefined ? [] : [[key, value]];
300
315
  }),
301
- ) as Partial<PolishedTuiConfig["icons"]>;
316
+ ) as Partial<IconGlyphs>;
302
317
  }
303
318
 
304
319
  function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
@@ -473,7 +488,8 @@ export function ensureConfigExists(): void {
473
488
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
474
489
  const config = isRecord(parsed) ? parsed : {};
475
490
  const iconsRecord = isRecord(config.icons) ? (config.icons as Record<string, unknown>) : {};
476
- const icons = normalizeIcons(iconsRecord);
491
+ const iconMode = normalizeIconMode(iconsRecord.mode);
492
+ const iconOverrides = normalizeIconOverrides(iconsRecord);
477
493
  const colors = isRecord(config.colors)
478
494
  ? normalizeColors(config.colors as Record<string, unknown>)
479
495
  : {};
@@ -492,11 +508,10 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
492
508
  return {
493
509
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
494
510
  footerFormat: stringValue(config, "footerFormat") ?? "",
495
- icons: {
496
- ...defaultConfig.icons,
497
- ...icons,
498
- rail: railValue(iconsRecord.rail),
499
- },
511
+ contextStyle: parseContextStyle(config.contextStyle),
512
+ contextThresholds: parseContextThresholds(config.contextThresholds),
513
+ pathDisplay: parsePathDisplay(config.pathDisplay),
514
+ icons: resolveConfiguredIcons(iconMode, iconOverrides),
500
515
  colors: {
501
516
  ...defaultConfig.colors,
502
517
  ...colors,
@@ -590,6 +605,55 @@ export function saveFooterFormatPatch(value: string, path = configPath): Polishe
590
605
  return mergeConfig(record);
591
606
  }
592
607
 
608
+ export function saveIconsModePatch(mode: IconMode, path = configPath): PolishedTuiConfig {
609
+ const record = readConfigRecord(path);
610
+ const existing = isRecord(record.icons) ? { ...(record.icons as Record<string, unknown>) } : {};
611
+ record.icons = {
612
+ ...existing,
613
+ mode: normalizeIconMode(mode),
614
+ };
615
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
616
+ return mergeConfig(record);
617
+ }
618
+
619
+ export function saveContextStylePatch(style: ContextStyle, path = configPath): PolishedTuiConfig {
620
+ const record = readConfigRecord(path);
621
+ record.contextStyle = parseContextStyle(style);
622
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
623
+ return mergeConfig(record);
624
+ }
625
+
626
+ export function saveContextThresholdsPatch(
627
+ thresholds: Partial<ContextThresholds>,
628
+ path = configPath,
629
+ ): PolishedTuiConfig {
630
+ const record = readConfigRecord(path);
631
+ const existing = isRecord(record.contextThresholds)
632
+ ? { ...(record.contextThresholds as Record<string, unknown>) }
633
+ : {};
634
+ record.contextThresholds = {
635
+ ...existing,
636
+ ...thresholds,
637
+ };
638
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
639
+ return mergeConfig(record);
640
+ }
641
+
642
+ export function savePathDisplayPatch(
643
+ patch: Partial<PathDisplayConfig>,
644
+ path = configPath,
645
+ ): PolishedTuiConfig {
646
+ const record = readConfigRecord(path);
647
+ const existing = isRecord(record.pathDisplay)
648
+ ? { ...(record.pathDisplay as Record<string, unknown>) }
649
+ : {};
650
+ if (patch.mode !== undefined) existing.mode = patch.mode;
651
+ if (patch.depth !== undefined) existing.depth = patch.depth;
652
+ record.pathDisplay = existing;
653
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
654
+ return mergeConfig(record);
655
+ }
656
+
593
657
  export function saveExtensionStatusPlacement(
594
658
  key: string,
595
659
  placement: ExtensionStatusPlacement,