pi-zentui 0.3.3 → 0.4.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 +72 -3
- package/extensions/zentui/config.ts +70 -1
- package/extensions/zentui/footer-format.ts +121 -0
- package/extensions/zentui/footer.ts +119 -4
- package/extensions/zentui/format.ts +31 -0
- package/extensions/zentui/index.ts +18 -1
- package/extensions/zentui/settings-command.ts +46 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -21,8 +21,10 @@ Zentui brings two popular aesthetics to Pi:
|
|
|
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
|
|
24
|
+
- Optional segments (off by default): `user@host`, current time, OS icon, and session duration
|
|
24
25
|
- Right side shows context usage, token counts, and cost
|
|
25
26
|
- Built-in footer segments can be shown or hidden individually from `/zentui`
|
|
27
|
+
- Fully custom Starship-style layout via a `footerFormat` template string — see [Footer Format Template](#footer-format-template)
|
|
26
28
|
- Third-party Pi extension statuses from `ctx.ui.setStatus()` can be shown on the left,
|
|
27
29
|
middle, or right side, or hidden per status key from `/zentui`
|
|
28
30
|
|
|
@@ -141,6 +143,8 @@ Useful slash-command shortcuts:
|
|
|
141
143
|
/zentui copy-friendly enable
|
|
142
144
|
/zentui copy-friendly disable
|
|
143
145
|
/zentui copy-friendly toggle
|
|
146
|
+
/zentui format "$cwd on branch $git_branch$git_status using $runtime $fill $context"
|
|
147
|
+
/zentui format clear
|
|
144
148
|
```
|
|
145
149
|
|
|
146
150
|
Default config values — copy this and change any value you want:
|
|
@@ -148,6 +152,7 @@ Default config values — copy this and change any value you want:
|
|
|
148
152
|
```json
|
|
149
153
|
{
|
|
150
154
|
"projectRefreshIntervalMs": 30000,
|
|
155
|
+
"footerFormat": "",
|
|
151
156
|
"icons": {
|
|
152
157
|
"cwd": "",
|
|
153
158
|
"git": "",
|
|
@@ -164,7 +169,10 @@ Default config values — copy this and change any value you want:
|
|
|
164
169
|
"typechanged": "T",
|
|
165
170
|
"cacheHit": "",
|
|
166
171
|
"editorPrompt": "",
|
|
167
|
-
"rail": "│"
|
|
172
|
+
"rail": "│",
|
|
173
|
+
"username": "",
|
|
174
|
+
"time": "",
|
|
175
|
+
"os": ""
|
|
168
176
|
},
|
|
169
177
|
"colors": {
|
|
170
178
|
"cwd": "bold cyan",
|
|
@@ -178,6 +186,10 @@ Default config values — copy this and change any value you want:
|
|
|
178
186
|
"extensionStatus": "bright-black",
|
|
179
187
|
"separator": "bright-black",
|
|
180
188
|
"runtimePrefix": "",
|
|
189
|
+
"sessionDuration": "yellow",
|
|
190
|
+
"username": "bold yellow",
|
|
191
|
+
"time": "bold yellow",
|
|
192
|
+
"os": "bold white",
|
|
181
193
|
"editorAccent": "accent",
|
|
182
194
|
"editorPrompt": "accent",
|
|
183
195
|
"editorBorder": "borderMuted",
|
|
@@ -204,10 +216,15 @@ Default config values — copy this and change any value you want:
|
|
|
204
216
|
"cwd": true,
|
|
205
217
|
"gitBranch": true,
|
|
206
218
|
"gitStatus": true,
|
|
219
|
+
"gitCounts": false,
|
|
207
220
|
"runtime": true,
|
|
208
221
|
"context": true,
|
|
209
222
|
"tokens": true,
|
|
210
|
-
"cost": true
|
|
223
|
+
"cost": true,
|
|
224
|
+
"sessionDuration": false,
|
|
225
|
+
"username": false,
|
|
226
|
+
"time": false,
|
|
227
|
+
"os": false
|
|
211
228
|
},
|
|
212
229
|
"extensionStatuses": {
|
|
213
230
|
"defaultPlacement": "right",
|
|
@@ -222,7 +239,8 @@ Default config values — copy this and change any value you want:
|
|
|
222
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.
|
|
223
240
|
- `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
|
|
224
241
|
- `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.
|
|
225
|
-
- `footerSegments`: show or hide individual built-in footer segments (`cwd`, `gitBranch`, `gitStatus`, `runtime`, `context`, `tokens`, `cost`). Toggle them from the `Built-in segments` tab in `/zentui`.
|
|
242
|
+
- `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.
|
|
226
244
|
- `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.
|
|
227
245
|
- 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`.
|
|
228
246
|
- `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
|
|
@@ -232,6 +250,57 @@ Default config values — copy this and change any value you want:
|
|
|
232
250
|
|
|
233
251
|
Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
|
|
234
252
|
|
|
253
|
+
## Footer Format Template
|
|
254
|
+
|
|
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.
|
|
256
|
+
|
|
257
|
+
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
|
+
|
|
259
|
+
```json
|
|
260
|
+
{
|
|
261
|
+
"footerFormat": "$os $username $cwd on branch $git_branch$git_status using $runtime $fill $context | $tokens | $cost $time"
|
|
262
|
+
}
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Center the branch between directory and cost:
|
|
266
|
+
|
|
267
|
+
```json
|
|
268
|
+
{
|
|
269
|
+
"footerFormat": "$cwd $fill $git_branch $fill $cost"
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### Variables
|
|
274
|
+
|
|
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 |
|
|
289
|
+
|
|
290
|
+
### `$fill` behavior
|
|
291
|
+
|
|
292
|
+
| `$fill` count | Layout |
|
|
293
|
+
| ------------- | ------------------------------------------------------------------------ |
|
|
294
|
+
| 0 | everything left-aligned |
|
|
295
|
+
| 1 | tokens before → left, tokens after → right |
|
|
296
|
+
| 2 | before first → left, between → **centered middle**, after second → right |
|
|
297
|
+
| 3+ | first two count; extras ignored |
|
|
298
|
+
|
|
299
|
+
- Literal text (`on branch`, `using`, `\|`, spaces) is rendered verbatim — you control all spacing.
|
|
300
|
+
- Each variable renders its core value only (no `on`/`via` prefixes); add those words as literal text.
|
|
301
|
+
- Unknown `$variables` render empty.
|
|
302
|
+
- Set or clear at runtime: `/zentui format "<template>"` and `/zentui format clear`.
|
|
303
|
+
|
|
235
304
|
## Requirements
|
|
236
305
|
|
|
237
306
|
- [Pi](https://pi.dev) coding agent 0.79 or newer
|
|
@@ -28,6 +28,9 @@ export type FooterSegmentsConfig = {
|
|
|
28
28
|
tokens: boolean;
|
|
29
29
|
cost: boolean;
|
|
30
30
|
sessionDuration: boolean;
|
|
31
|
+
username: boolean;
|
|
32
|
+
time: boolean;
|
|
33
|
+
os: boolean;
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
|
|
@@ -46,6 +49,7 @@ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
|
46
49
|
|
|
47
50
|
export type PolishedTuiConfig = {
|
|
48
51
|
projectRefreshIntervalMs: number;
|
|
52
|
+
footerFormat: string;
|
|
49
53
|
icons: {
|
|
50
54
|
cwd: string;
|
|
51
55
|
git: string;
|
|
@@ -63,6 +67,9 @@ export type PolishedTuiConfig = {
|
|
|
63
67
|
cacheHit: string;
|
|
64
68
|
editorPrompt: string;
|
|
65
69
|
rail: string;
|
|
70
|
+
username: string;
|
|
71
|
+
time: string;
|
|
72
|
+
os: string;
|
|
66
73
|
};
|
|
67
74
|
colors: {
|
|
68
75
|
cwd: ColorSpec;
|
|
@@ -77,6 +84,9 @@ export type PolishedTuiConfig = {
|
|
|
77
84
|
runtimePrefix: ColorSpec;
|
|
78
85
|
extensionStatus: ColorSpec;
|
|
79
86
|
sessionDuration: ColorSpec;
|
|
87
|
+
username: ColorSpec;
|
|
88
|
+
time: ColorSpec;
|
|
89
|
+
os: ColorSpec;
|
|
80
90
|
editorAccent?: ColorSpec;
|
|
81
91
|
editorPrompt?: ColorSpec;
|
|
82
92
|
editorBorder?: ColorSpec;
|
|
@@ -95,10 +105,40 @@ export type PolishedTuiConfig = {
|
|
|
95
105
|
extensionStatuses: ExtensionStatusesConfig;
|
|
96
106
|
};
|
|
97
107
|
|
|
108
|
+
/**
|
|
109
|
+
* Canonical footer format variable names. In a `footerFormat` string these
|
|
110
|
+
* are written as `$name` or `${name}`.
|
|
111
|
+
*/
|
|
112
|
+
export const FOOTER_FORMAT_VARIABLES = [
|
|
113
|
+
"cwd",
|
|
114
|
+
"git_branch",
|
|
115
|
+
"git_status",
|
|
116
|
+
"runtime",
|
|
117
|
+
"session_duration",
|
|
118
|
+
"username",
|
|
119
|
+
"os",
|
|
120
|
+
"time",
|
|
121
|
+
"context",
|
|
122
|
+
"tokens",
|
|
123
|
+
"cost",
|
|
124
|
+
] as const;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Alias → canonical variable name mapping for `footerFormat`.
|
|
128
|
+
* `$fill` is special (not a variable) and handled by the parser.
|
|
129
|
+
*/
|
|
130
|
+
export const FOOTER_FORMAT_ALIASES: Record<string, string> = {
|
|
131
|
+
directory: "cwd",
|
|
132
|
+
branch: "git_branch",
|
|
133
|
+
status: "git_status",
|
|
134
|
+
duration: "session_duration",
|
|
135
|
+
};
|
|
136
|
+
|
|
98
137
|
export const configPath = join(getAgentDir(), "zentui.json");
|
|
99
138
|
|
|
100
139
|
export const defaultConfig: PolishedTuiConfig = {
|
|
101
140
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
141
|
+
footerFormat: "",
|
|
102
142
|
icons: {
|
|
103
143
|
cwd: "",
|
|
104
144
|
git: "",
|
|
@@ -116,6 +156,9 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
116
156
|
cacheHit: "",
|
|
117
157
|
editorPrompt: "",
|
|
118
158
|
rail: "│",
|
|
159
|
+
username: "",
|
|
160
|
+
time: "",
|
|
161
|
+
os: "",
|
|
119
162
|
},
|
|
120
163
|
colors: {
|
|
121
164
|
cwd: "bold cyan",
|
|
@@ -130,6 +173,9 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
130
173
|
runtimePrefix: "",
|
|
131
174
|
extensionStatus: "bright-black",
|
|
132
175
|
sessionDuration: "yellow",
|
|
176
|
+
username: "bold yellow",
|
|
177
|
+
time: "bold yellow",
|
|
178
|
+
os: "bold white",
|
|
133
179
|
},
|
|
134
180
|
colorSources: {
|
|
135
181
|
starship: "theme",
|
|
@@ -151,6 +197,9 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
151
197
|
tokens: true,
|
|
152
198
|
cost: true,
|
|
153
199
|
sessionDuration: false,
|
|
200
|
+
username: false,
|
|
201
|
+
time: false,
|
|
202
|
+
os: false,
|
|
154
203
|
},
|
|
155
204
|
extensionStatuses: {
|
|
156
205
|
defaultPlacement: "right",
|
|
@@ -175,6 +224,9 @@ const iconKeys = [
|
|
|
175
224
|
"typechanged",
|
|
176
225
|
"cacheHit",
|
|
177
226
|
"editorPrompt",
|
|
227
|
+
"username",
|
|
228
|
+
"time",
|
|
229
|
+
"os",
|
|
178
230
|
] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
|
|
179
231
|
|
|
180
232
|
type ConfigRecord = Record<string, unknown>;
|
|
@@ -263,6 +315,9 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
|
|
|
263
315
|
runtimePrefix: colorValue(record, "runtimePrefix"),
|
|
264
316
|
extensionStatus: colorValue(record, "extensionStatus"),
|
|
265
317
|
sessionDuration: colorValue(record, "sessionDuration"),
|
|
318
|
+
username: colorValue(record, "username"),
|
|
319
|
+
time: colorValue(record, "time"),
|
|
320
|
+
os: colorValue(record, "os"),
|
|
266
321
|
editorAccent: colorValue(record, "editorAccent"),
|
|
267
322
|
editorPrompt: colorValue(record, "editorPrompt"),
|
|
268
323
|
editorBorder: colorValue(record, "editorBorder"),
|
|
@@ -304,6 +359,9 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
|
|
|
304
359
|
tokens: footerSegmentValue(record, "tokens"),
|
|
305
360
|
cost: footerSegmentValue(record, "cost"),
|
|
306
361
|
sessionDuration: footerSegmentValue(record, "sessionDuration"),
|
|
362
|
+
username: footerSegmentValue(record, "username"),
|
|
363
|
+
time: footerSegmentValue(record, "time"),
|
|
364
|
+
os: footerSegmentValue(record, "os"),
|
|
307
365
|
};
|
|
308
366
|
}
|
|
309
367
|
|
|
@@ -361,7 +419,10 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
|
|
|
361
419
|
value === "context" ||
|
|
362
420
|
value === "tokens" ||
|
|
363
421
|
value === "cost" ||
|
|
364
|
-
value === "sessionDuration"
|
|
422
|
+
value === "sessionDuration" ||
|
|
423
|
+
value === "username" ||
|
|
424
|
+
value === "time" ||
|
|
425
|
+
value === "os"
|
|
365
426
|
);
|
|
366
427
|
}
|
|
367
428
|
|
|
@@ -430,6 +491,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
430
491
|
: defaultConfig.extensionStatuses;
|
|
431
492
|
return {
|
|
432
493
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
494
|
+
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
433
495
|
icons: {
|
|
434
496
|
...defaultConfig.icons,
|
|
435
497
|
...icons,
|
|
@@ -521,6 +583,13 @@ export function saveFooterSegmentsPatch(
|
|
|
521
583
|
return mergeConfig(record);
|
|
522
584
|
}
|
|
523
585
|
|
|
586
|
+
export function saveFooterFormatPatch(value: string, path = configPath): PolishedTuiConfig {
|
|
587
|
+
const record = readConfigRecord(path);
|
|
588
|
+
record.footerFormat = typeof value === "string" ? value : "";
|
|
589
|
+
writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
|
|
590
|
+
return mergeConfig(record);
|
|
591
|
+
}
|
|
592
|
+
|
|
524
593
|
export function saveExtensionStatusPlacement(
|
|
525
594
|
key: string,
|
|
526
595
|
placement: ExtensionStatusPlacement,
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starship-style footer format string parser and renderer.
|
|
3
|
+
*
|
|
4
|
+
* Pure module (no TUI/config imports) so it is fully unit-testable.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type FormatToken =
|
|
8
|
+
| { kind: "text"; value: string }
|
|
9
|
+
| { kind: "var"; name: string }
|
|
10
|
+
| { kind: "fill" };
|
|
11
|
+
|
|
12
|
+
const TOKEN_REGEX = /\$\{([a-zA-Z_][a-zA-Z0-9_]*)\}|\$([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Tokenize a format string into text/var/fill tokens.
|
|
16
|
+
*
|
|
17
|
+
* `$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.
|
|
20
|
+
*/
|
|
21
|
+
export function parseFooterFormat(format: string): FormatToken[] {
|
|
22
|
+
if (!format) return [];
|
|
23
|
+
|
|
24
|
+
const tokens: FormatToken[] = [];
|
|
25
|
+
let lastIndex = 0;
|
|
26
|
+
|
|
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) });
|
|
30
|
+
}
|
|
31
|
+
const name = match[1] ?? match[2];
|
|
32
|
+
if (name === "fill") {
|
|
33
|
+
tokens.push({ kind: "fill" });
|
|
34
|
+
} else {
|
|
35
|
+
tokens.push({ kind: "var", name });
|
|
36
|
+
}
|
|
37
|
+
lastIndex = (match.index ?? 0) + match[0].length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (lastIndex < format.length) {
|
|
41
|
+
tokens.push({ kind: "text", value: format.slice(lastIndex) });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return tokens;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render tokens into `{ left, middle, right }` based on `$fill` markers.
|
|
49
|
+
*
|
|
50
|
+
* - No fill: everything → `left`; `middle` and `right` are `""`.
|
|
51
|
+
* - One fill: tokens before → `left`, tokens after → `right`; `middle` is `""`.
|
|
52
|
+
* - Two fills: before the first → `left`, between the two → `middle`
|
|
53
|
+
* (centered by the caller via the existing middle-zone logic), after the
|
|
54
|
+
* second → `right`.
|
|
55
|
+
* - Additional fills beyond the first two are ignored.
|
|
56
|
+
*
|
|
57
|
+
* Text tokens contribute their `value` verbatim (unstyled/plain); var tokens
|
|
58
|
+
* contribute `renderVariable(name)` (already styled by caller). No automatic
|
|
59
|
+
* spaces are inserted — the user controls all spacing.
|
|
60
|
+
*/
|
|
61
|
+
export function renderFormatSplit(
|
|
62
|
+
tokens: FormatToken[],
|
|
63
|
+
renderVariable: (name: string) => string,
|
|
64
|
+
): { 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
|
+
}
|
|
69
|
+
|
|
70
|
+
if (fillIndices.length === 0) {
|
|
71
|
+
return {
|
|
72
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
73
|
+
middle: "",
|
|
74
|
+
right: "",
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const first = fillIndices[0];
|
|
79
|
+
const second = fillIndices[1];
|
|
80
|
+
|
|
81
|
+
if (first === undefined) {
|
|
82
|
+
return {
|
|
83
|
+
left: renderTokenSlice(tokens, 0, tokens.length, renderVariable),
|
|
84
|
+
middle: "",
|
|
85
|
+
right: "",
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (second === undefined) {
|
|
90
|
+
return {
|
|
91
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
92
|
+
middle: "",
|
|
93
|
+
right: renderTokenSlice(tokens, first + 1, tokens.length, renderVariable),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
left: renderTokenSlice(tokens, 0, first, renderVariable),
|
|
99
|
+
middle: renderTokenSlice(tokens, first + 1, second, renderVariable),
|
|
100
|
+
right: renderTokenSlice(tokens, second + 1, tokens.length, renderVariable),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function renderTokenSlice(
|
|
105
|
+
tokens: FormatToken[],
|
|
106
|
+
start: number,
|
|
107
|
+
end: number,
|
|
108
|
+
renderVariable: (name: string) => string,
|
|
109
|
+
): string {
|
|
110
|
+
let result = "";
|
|
111
|
+
for (let i = start; i < end; i++) {
|
|
112
|
+
const token = tokens[i];
|
|
113
|
+
if (!token) continue;
|
|
114
|
+
if (token.kind === "text") {
|
|
115
|
+
result += token.value;
|
|
116
|
+
} else if (token.kind === "var") {
|
|
117
|
+
result += renderVariable(token.name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}
|
|
@@ -1,8 +1,17 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
3
|
import type { PolishedTuiConfig } from "./config";
|
|
4
|
+
import { FOOTER_FORMAT_ALIASES } from "./config";
|
|
4
5
|
import { collectExtensionStatusSegments, type ExtensionStatusSegment } from "./extension-status";
|
|
5
|
-
import {
|
|
6
|
+
import { parseFooterFormat, renderFormatSplit } from "./footer-format";
|
|
7
|
+
import {
|
|
8
|
+
buildSessionDurationLabel,
|
|
9
|
+
formatCwdLabel,
|
|
10
|
+
formatOsLabel,
|
|
11
|
+
formatRuntimeSegment,
|
|
12
|
+
formatTimeLabel,
|
|
13
|
+
formatUsernameHostLabel,
|
|
14
|
+
} from "./format";
|
|
6
15
|
import type { FooterState } from "./state";
|
|
7
16
|
import { renderStyleForSource } from "./style";
|
|
8
17
|
|
|
@@ -207,6 +216,67 @@ export function installFooter(
|
|
|
207
216
|
})();
|
|
208
217
|
const statusBlock =
|
|
209
218
|
allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
|
|
219
|
+
const renderVariable = (name: string): string => {
|
|
220
|
+
const canonical = FOOTER_FORMAT_ALIASES[name] ?? name;
|
|
221
|
+
switch (canonical) {
|
|
222
|
+
case "cwd":
|
|
223
|
+
return cwdLabel;
|
|
224
|
+
case "git_branch":
|
|
225
|
+
return branch ? (gitIcon ? `${gitIcon} ${gitColor(branch)}` : gitColor(branch)) : "";
|
|
226
|
+
case "git_status":
|
|
227
|
+
return statusBlock;
|
|
228
|
+
case "runtime": {
|
|
229
|
+
if (!state.runtime) return "";
|
|
230
|
+
const label = state.runtime.version
|
|
231
|
+
? `${state.runtime.symbol} ${state.runtime.version}`
|
|
232
|
+
: state.runtime.symbol;
|
|
233
|
+
return renderStyleForSource(theme, colorSource, state.runtime.style, label);
|
|
234
|
+
}
|
|
235
|
+
case "session_duration":
|
|
236
|
+
return state.sessionStartEpoch
|
|
237
|
+
? renderStyleForSource(
|
|
238
|
+
theme,
|
|
239
|
+
colorSource,
|
|
240
|
+
config.colors.sessionDuration,
|
|
241
|
+
buildSessionDurationLabel(state.sessionStartEpoch),
|
|
242
|
+
)
|
|
243
|
+
: "";
|
|
244
|
+
case "username":
|
|
245
|
+
return renderStyleForSource(
|
|
246
|
+
theme,
|
|
247
|
+
colorSource,
|
|
248
|
+
config.colors.username,
|
|
249
|
+
formatUsernameHostLabel(config.icons.username),
|
|
250
|
+
);
|
|
251
|
+
case "os":
|
|
252
|
+
return renderStyleForSource(
|
|
253
|
+
theme,
|
|
254
|
+
colorSource,
|
|
255
|
+
config.colors.os,
|
|
256
|
+
formatOsLabel(config.icons.os),
|
|
257
|
+
);
|
|
258
|
+
case "time":
|
|
259
|
+
return renderStyleForSource(
|
|
260
|
+
theme,
|
|
261
|
+
colorSource,
|
|
262
|
+
config.colors.time,
|
|
263
|
+
formatTimeLabel(config.icons.time),
|
|
264
|
+
);
|
|
265
|
+
case "context":
|
|
266
|
+
return renderStyleForSource(theme, colorSource, contextColor, state.contextLabel);
|
|
267
|
+
case "tokens":
|
|
268
|
+
return renderStyleForSource(
|
|
269
|
+
theme,
|
|
270
|
+
colorSource,
|
|
271
|
+
config.colors.tokens,
|
|
272
|
+
state.tokenLabel,
|
|
273
|
+
);
|
|
274
|
+
case "cost":
|
|
275
|
+
return renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel);
|
|
276
|
+
default:
|
|
277
|
+
return "";
|
|
278
|
+
}
|
|
279
|
+
};
|
|
210
280
|
const branchParts =
|
|
211
281
|
config.footerSegments.gitBranch && branch
|
|
212
282
|
? ["on", gitIcon, gitColor(branch)].filter(Boolean)
|
|
@@ -229,7 +299,25 @@ export function installFooter(
|
|
|
229
299
|
);
|
|
230
300
|
return `${prefix} ${time}`;
|
|
231
301
|
})();
|
|
302
|
+
const usernameSegment = config.footerSegments.username
|
|
303
|
+
? renderStyleForSource(
|
|
304
|
+
theme,
|
|
305
|
+
colorSource,
|
|
306
|
+
config.colors.username,
|
|
307
|
+
formatUsernameHostLabel(config.icons.username),
|
|
308
|
+
)
|
|
309
|
+
: "";
|
|
310
|
+
const osSegment = config.footerSegments.os
|
|
311
|
+
? renderStyleForSource(
|
|
312
|
+
theme,
|
|
313
|
+
colorSource,
|
|
314
|
+
config.colors.os,
|
|
315
|
+
formatOsLabel(config.icons.os),
|
|
316
|
+
)
|
|
317
|
+
: "";
|
|
232
318
|
const left = [
|
|
319
|
+
osSegment,
|
|
320
|
+
usernameSegment,
|
|
233
321
|
config.footerSegments.cwd ? cwdLabel : "",
|
|
234
322
|
branchLabel,
|
|
235
323
|
runtimeLabel,
|
|
@@ -238,6 +326,14 @@ export function installFooter(
|
|
|
238
326
|
.filter(Boolean)
|
|
239
327
|
.join(" ");
|
|
240
328
|
|
|
329
|
+
const timeSegment = config.footerSegments.time
|
|
330
|
+
? renderStyleForSource(
|
|
331
|
+
theme,
|
|
332
|
+
colorSource,
|
|
333
|
+
config.colors.time,
|
|
334
|
+
formatTimeLabel(config.icons.time),
|
|
335
|
+
)
|
|
336
|
+
: "";
|
|
241
337
|
const right = [
|
|
242
338
|
config.footerSegments.context
|
|
243
339
|
? renderStyleForSource(theme, colorSource, contextColor, state.contextLabel)
|
|
@@ -248,10 +344,25 @@ export function installFooter(
|
|
|
248
344
|
config.footerSegments.cost
|
|
249
345
|
? renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel)
|
|
250
346
|
: "",
|
|
347
|
+
timeSegment,
|
|
251
348
|
]
|
|
252
349
|
.filter(Boolean)
|
|
253
350
|
.join(separator);
|
|
254
351
|
|
|
352
|
+
let contentLeft = left;
|
|
353
|
+
let contentMiddle = "";
|
|
354
|
+
let contentRight = right;
|
|
355
|
+
if (config.footerFormat) {
|
|
356
|
+
const {
|
|
357
|
+
left: fmtLeft,
|
|
358
|
+
middle: fmtMiddle,
|
|
359
|
+
right: fmtRight,
|
|
360
|
+
} = renderFormatSplit(parseFooterFormat(config.footerFormat), renderVariable);
|
|
361
|
+
contentLeft = fmtLeft;
|
|
362
|
+
contentMiddle = fmtMiddle;
|
|
363
|
+
contentRight = fmtRight;
|
|
364
|
+
}
|
|
365
|
+
|
|
255
366
|
const extensionStatuses = collectExtensionStatusSegments(
|
|
256
367
|
footerData.getExtensionStatuses(),
|
|
257
368
|
config,
|
|
@@ -260,11 +371,15 @@ export function installFooter(
|
|
|
260
371
|
segment.colorMode === "original"
|
|
261
372
|
? segment.text
|
|
262
373
|
: renderStyleForSource(theme, colorSource, config.colors.extensionStatus, segment.text);
|
|
374
|
+
const extensionMiddleSegments = extensionStatuses.middle.map(renderExtensionStatus);
|
|
375
|
+
const middleSegments = contentMiddle
|
|
376
|
+
? [contentMiddle, ...extensionMiddleSegments]
|
|
377
|
+
: extensionMiddleSegments;
|
|
263
378
|
const content = composeFooterContent(
|
|
264
|
-
|
|
265
|
-
|
|
379
|
+
contentLeft,
|
|
380
|
+
contentRight,
|
|
266
381
|
extensionStatuses.left.map(renderExtensionStatus),
|
|
267
|
-
|
|
382
|
+
middleSegments,
|
|
268
383
|
extensionStatuses.right.map(renderExtensionStatus),
|
|
269
384
|
separator,
|
|
270
385
|
innerWidth,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { hostname, userInfo } from "node:os";
|
|
1
2
|
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
2
3
|
import type { ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
3
4
|
import type { ColorSource, ColorSpec } from "./config";
|
|
@@ -129,3 +130,33 @@ export function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
|
129
130
|
const last = parts[parts.length - 1] ?? cwd;
|
|
130
131
|
return cwdIcon ? `${cwdIcon} ${last}` : last;
|
|
131
132
|
}
|
|
133
|
+
|
|
134
|
+
export function formatUsernameHostLabel(icon: string): string {
|
|
135
|
+
try {
|
|
136
|
+
const user = userInfo().username;
|
|
137
|
+
const host = hostname();
|
|
138
|
+
if (!user || !host) return "";
|
|
139
|
+
const label = `${user}@${host}`;
|
|
140
|
+
return icon ? `${icon} ${label}` : label;
|
|
141
|
+
} catch {
|
|
142
|
+
return "";
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function formatTimeLabel(icon: string): string {
|
|
147
|
+
const now = new Date();
|
|
148
|
+
const hours = String(now.getHours()).padStart(2, "0");
|
|
149
|
+
const minutes = String(now.getMinutes()).padStart(2, "0");
|
|
150
|
+
const label = `${hours}:${minutes}`;
|
|
151
|
+
return icon ? `${icon} ${label}` : label;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const osIconMap: Record<string, string> = {
|
|
155
|
+
darwin: "\uf179",
|
|
156
|
+
linux: "\uf17c",
|
|
157
|
+
win32: "\uf17a",
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export function formatOsLabel(defaultIcon: string): string {
|
|
161
|
+
return osIconMap[process.platform] ?? defaultIcon;
|
|
162
|
+
}
|
|
@@ -16,6 +16,7 @@ import {
|
|
|
16
16
|
saveColorSourcesPatch,
|
|
17
17
|
saveExtensionStatusColorMode,
|
|
18
18
|
saveExtensionStatusPlacement,
|
|
19
|
+
saveFooterFormatPatch,
|
|
19
20
|
saveFooterSegmentsPatch,
|
|
20
21
|
saveUiFeaturesPatch,
|
|
21
22
|
type UiFeaturesConfig,
|
|
@@ -124,8 +125,21 @@ export default function (pi: ExtensionAPI) {
|
|
|
124
125
|
stopSessionTimer();
|
|
125
126
|
lastDurationLabel = "";
|
|
126
127
|
const timer = setInterval(() => {
|
|
127
|
-
|
|
128
|
+
const segments = currentConfig.footerSegments;
|
|
129
|
+
const formatNeedsTimer =
|
|
130
|
+
currentConfig.footerFormat &&
|
|
131
|
+
/\$\{?(?:time|session_duration|duration)\b/.test(currentConfig.footerFormat);
|
|
132
|
+
if (
|
|
133
|
+
!(
|
|
134
|
+
currentConfig.features.statusLine &&
|
|
135
|
+
(segments.sessionDuration || segments.time || formatNeedsTimer)
|
|
136
|
+
)
|
|
137
|
+
)
|
|
128
138
|
return;
|
|
139
|
+
if (segments.time || formatNeedsTimer) {
|
|
140
|
+
refresh();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
129
143
|
const label = state.sessionStartEpoch
|
|
130
144
|
? buildSessionDurationLabel(state.sessionStartEpoch)
|
|
131
145
|
: "";
|
|
@@ -376,6 +390,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
376
390
|
setFooterSegments(patch: Partial<FooterSegmentsConfig>) {
|
|
377
391
|
currentConfig = saveFooterSegmentsPatch(patch);
|
|
378
392
|
},
|
|
393
|
+
setFooterFormat(value: string) {
|
|
394
|
+
currentConfig = saveFooterFormatPatch(value);
|
|
395
|
+
},
|
|
379
396
|
getActiveExtensionStatuses() {
|
|
380
397
|
return getActiveExtensionStatuses();
|
|
381
398
|
},
|
|
@@ -51,6 +51,7 @@ type SettingsCommandDeps = {
|
|
|
51
51
|
ctx: ExtensionContext,
|
|
52
52
|
) => { applied: boolean; reason?: string };
|
|
53
53
|
setFooterSegments: (patch: Partial<FooterSegmentsConfig>) => void;
|
|
54
|
+
setFooterFormat: (value: string) => void;
|
|
54
55
|
getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
|
|
55
56
|
setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
|
|
56
57
|
setExtensionStatusColorMode: (key: string, colorMode: ExtensionStatusColorMode) => void;
|
|
@@ -90,6 +91,9 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
|
|
|
90
91
|
gitStatus: "Git status",
|
|
91
92
|
gitCounts: "Git counts",
|
|
92
93
|
sessionDuration: "Session duration",
|
|
94
|
+
username: "Username@host",
|
|
95
|
+
time: "Current time",
|
|
96
|
+
os: "OS icon",
|
|
93
97
|
runtime: "Runtime",
|
|
94
98
|
context: "Context usage",
|
|
95
99
|
tokens: "Token counts",
|
|
@@ -103,6 +107,9 @@ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> =
|
|
|
103
107
|
gitCounts:
|
|
104
108
|
"Show numeric ahead/behind and stash counts (requires the Git status segment to be enabled).",
|
|
105
109
|
sessionDuration: "Show session running time on the left, after the runtime.",
|
|
110
|
+
username: "Show user@hostname on the left.",
|
|
111
|
+
time: "Show the current time (HH:MM) on the right.",
|
|
112
|
+
os: "Show an operating-system icon on the left.",
|
|
106
113
|
runtime: "Show or hide the detected runtime/language segment on the left.",
|
|
107
114
|
context: "Show or hide context usage on the right.",
|
|
108
115
|
tokens: "Show or hide input/output token counts on the right.",
|
|
@@ -119,6 +126,8 @@ const directCommandSuggestions = [
|
|
|
119
126
|
"copy-friendly enable",
|
|
120
127
|
"copy-friendly disable",
|
|
121
128
|
"copy-friendly toggle",
|
|
129
|
+
"format clear",
|
|
130
|
+
"format $cwd on $git_branch $fill $context",
|
|
122
131
|
];
|
|
123
132
|
|
|
124
133
|
const sectionLabels: Record<SettingsSection, string> = {
|
|
@@ -154,7 +163,10 @@ function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingI
|
|
|
154
163
|
value === "runtime" ||
|
|
155
164
|
value === "context" ||
|
|
156
165
|
value === "tokens" ||
|
|
157
|
-
value === "cost"
|
|
166
|
+
value === "cost" ||
|
|
167
|
+
value === "username" ||
|
|
168
|
+
value === "time" ||
|
|
169
|
+
value === "os"
|
|
158
170
|
);
|
|
159
171
|
}
|
|
160
172
|
|
|
@@ -198,7 +210,7 @@ function footerSegmentPatch(
|
|
|
198
210
|
}
|
|
199
211
|
|
|
200
212
|
function usageText(): string {
|
|
201
|
-
return
|
|
213
|
+
return 'Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle] or /zentui format "<template>"';
|
|
202
214
|
}
|
|
203
215
|
|
|
204
216
|
function featureNotification(
|
|
@@ -242,6 +254,18 @@ function parseDirectFeatureCommand(
|
|
|
242
254
|
};
|
|
243
255
|
}
|
|
244
256
|
|
|
257
|
+
function parseFormatCommand(args: string): { value: string | undefined } | undefined {
|
|
258
|
+
const trimmed = args.trim();
|
|
259
|
+
if (!trimmed.toLowerCase().startsWith("format")) return undefined;
|
|
260
|
+
|
|
261
|
+
const rest = trimmed.slice("format".length).trim();
|
|
262
|
+
if (!rest || rest.toLowerCase() === "clear") return { value: undefined };
|
|
263
|
+
|
|
264
|
+
const unquoted =
|
|
265
|
+
rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2 ? rest.slice(1, -1) : rest;
|
|
266
|
+
return { value: unquoted };
|
|
267
|
+
}
|
|
268
|
+
|
|
245
269
|
function argumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
246
270
|
const trimmedPrefix = prefix.trimStart().toLowerCase();
|
|
247
271
|
const items = directCommandSuggestions.map((value) => ({ value, label: value }));
|
|
@@ -383,6 +407,26 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
383
407
|
getArgumentCompletions: argumentCompletions,
|
|
384
408
|
handler: async (_args, ctx) => {
|
|
385
409
|
const args = typeof _args === "string" ? _args : "";
|
|
410
|
+
|
|
411
|
+
const formatCommand = parseFormatCommand(args);
|
|
412
|
+
if (formatCommand) {
|
|
413
|
+
try {
|
|
414
|
+
deps.setFooterFormat(formatCommand.value ?? "");
|
|
415
|
+
deps.requestRender();
|
|
416
|
+
if (ctx.hasUI) {
|
|
417
|
+
if (formatCommand.value === undefined) {
|
|
418
|
+
ctx.ui.notify("Footer format cleared (using default layout)", "info");
|
|
419
|
+
} else {
|
|
420
|
+
ctx.ui.notify(`Footer format: ${formatCommand.value}`, "info");
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
if (ctx.hasUI) ctx.ui.notify(`Could not update footer format: ${message}`, "error");
|
|
426
|
+
}
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
|
|
386
430
|
const directCommand = parseDirectFeatureCommand(args, deps.getConfig());
|
|
387
431
|
if (directCommand) {
|
|
388
432
|
try {
|