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 +33 -22
- package/extensions/zentui/config.ts +102 -76
- package/extensions/zentui/footer-format.ts +184 -26
- package/extensions/zentui/footer.ts +49 -20
- package/extensions/zentui/format.ts +133 -21
- package/extensions/zentui/git.ts +140 -4
- package/extensions/zentui/icons.ts +225 -0
- package/extensions/zentui/index.ts +30 -10
- package/extensions/zentui/project-state.ts +44 -0
- package/extensions/zentui/runtime.ts +64 -11
- package/extensions/zentui/settings-command.ts +64 -1
- package/extensions/zentui/style.ts +13 -2
- package/package.json +1 -1
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
|
|
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
|
-
- `
|
|
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
|
|
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
|
|
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
|
-
| `$
|
|
281
|
-
| `$
|
|
282
|
-
| `$
|
|
283
|
-
| `$
|
|
284
|
-
| `$
|
|
285
|
-
| `$
|
|
286
|
-
| `$
|
|
287
|
-
| `$
|
|
288
|
-
| `$
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
144
|
-
|
|
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
|
|
208
|
+
return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
|
|
242
209
|
}
|
|
243
210
|
|
|
244
211
|
const interval = Math.round(value);
|
|
245
|
-
|
|
246
|
-
|
|
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
|
|
251
|
-
return
|
|
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
|
|
288
|
+
function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGlyphs> {
|
|
296
289
|
return Object.fromEntries(
|
|
297
|
-
|
|
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<
|
|
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
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
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.
|
|
19
|
-
*
|
|
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
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
if (
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
}
|