pi-zentui 0.3.2 → 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/LICENSE +1 -1
- package/README.md +72 -3
- package/extensions/zentui/config.ts +81 -1
- package/extensions/zentui/footer-format.ts +121 -0
- package/extensions/zentui/footer.ts +159 -14
- package/extensions/zentui/format.ts +41 -0
- package/extensions/zentui/git.ts +7 -6
- package/extensions/zentui/index.ts +43 -0
- package/extensions/zentui/settings-command.ts +53 -2
- package/extensions/zentui/state.ts +2 -0
- package/package.json +1 -1
package/LICENSE
CHANGED
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
|
|
@@ -22,10 +22,15 @@ export type FooterSegmentsConfig = {
|
|
|
22
22
|
cwd: boolean;
|
|
23
23
|
gitBranch: boolean;
|
|
24
24
|
gitStatus: boolean;
|
|
25
|
+
gitCounts: boolean;
|
|
25
26
|
runtime: boolean;
|
|
26
27
|
context: boolean;
|
|
27
28
|
tokens: boolean;
|
|
28
29
|
cost: boolean;
|
|
30
|
+
sessionDuration: boolean;
|
|
31
|
+
username: boolean;
|
|
32
|
+
time: boolean;
|
|
33
|
+
os: boolean;
|
|
29
34
|
};
|
|
30
35
|
|
|
31
36
|
export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
|
|
@@ -44,6 +49,7 @@ const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
|
|
|
44
49
|
|
|
45
50
|
export type PolishedTuiConfig = {
|
|
46
51
|
projectRefreshIntervalMs: number;
|
|
52
|
+
footerFormat: string;
|
|
47
53
|
icons: {
|
|
48
54
|
cwd: string;
|
|
49
55
|
git: string;
|
|
@@ -61,6 +67,9 @@ export type PolishedTuiConfig = {
|
|
|
61
67
|
cacheHit: string;
|
|
62
68
|
editorPrompt: string;
|
|
63
69
|
rail: string;
|
|
70
|
+
username: string;
|
|
71
|
+
time: string;
|
|
72
|
+
os: string;
|
|
64
73
|
};
|
|
65
74
|
colors: {
|
|
66
75
|
cwd: ColorSpec;
|
|
@@ -74,6 +83,10 @@ export type PolishedTuiConfig = {
|
|
|
74
83
|
separator: ColorSpec;
|
|
75
84
|
runtimePrefix: ColorSpec;
|
|
76
85
|
extensionStatus: ColorSpec;
|
|
86
|
+
sessionDuration: ColorSpec;
|
|
87
|
+
username: ColorSpec;
|
|
88
|
+
time: ColorSpec;
|
|
89
|
+
os: ColorSpec;
|
|
77
90
|
editorAccent?: ColorSpec;
|
|
78
91
|
editorPrompt?: ColorSpec;
|
|
79
92
|
editorBorder?: ColorSpec;
|
|
@@ -92,10 +105,40 @@ export type PolishedTuiConfig = {
|
|
|
92
105
|
extensionStatuses: ExtensionStatusesConfig;
|
|
93
106
|
};
|
|
94
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
|
+
|
|
95
137
|
export const configPath = join(getAgentDir(), "zentui.json");
|
|
96
138
|
|
|
97
139
|
export const defaultConfig: PolishedTuiConfig = {
|
|
98
140
|
projectRefreshIntervalMs: DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
|
|
141
|
+
footerFormat: "",
|
|
99
142
|
icons: {
|
|
100
143
|
cwd: "",
|
|
101
144
|
git: "",
|
|
@@ -113,6 +156,9 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
113
156
|
cacheHit: "",
|
|
114
157
|
editorPrompt: "",
|
|
115
158
|
rail: "│",
|
|
159
|
+
username: "",
|
|
160
|
+
time: "",
|
|
161
|
+
os: "",
|
|
116
162
|
},
|
|
117
163
|
colors: {
|
|
118
164
|
cwd: "bold cyan",
|
|
@@ -126,6 +172,10 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
126
172
|
separator: "bright-black",
|
|
127
173
|
runtimePrefix: "",
|
|
128
174
|
extensionStatus: "bright-black",
|
|
175
|
+
sessionDuration: "yellow",
|
|
176
|
+
username: "bold yellow",
|
|
177
|
+
time: "bold yellow",
|
|
178
|
+
os: "bold white",
|
|
129
179
|
},
|
|
130
180
|
colorSources: {
|
|
131
181
|
starship: "theme",
|
|
@@ -141,10 +191,15 @@ export const defaultConfig: PolishedTuiConfig = {
|
|
|
141
191
|
cwd: true,
|
|
142
192
|
gitBranch: true,
|
|
143
193
|
gitStatus: true,
|
|
194
|
+
gitCounts: false,
|
|
144
195
|
runtime: true,
|
|
145
196
|
context: true,
|
|
146
197
|
tokens: true,
|
|
147
198
|
cost: true,
|
|
199
|
+
sessionDuration: false,
|
|
200
|
+
username: false,
|
|
201
|
+
time: false,
|
|
202
|
+
os: false,
|
|
148
203
|
},
|
|
149
204
|
extensionStatuses: {
|
|
150
205
|
defaultPlacement: "right",
|
|
@@ -169,6 +224,9 @@ const iconKeys = [
|
|
|
169
224
|
"typechanged",
|
|
170
225
|
"cacheHit",
|
|
171
226
|
"editorPrompt",
|
|
227
|
+
"username",
|
|
228
|
+
"time",
|
|
229
|
+
"os",
|
|
172
230
|
] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
|
|
173
231
|
|
|
174
232
|
type ConfigRecord = Record<string, unknown>;
|
|
@@ -256,6 +314,10 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
|
|
|
256
314
|
separator: colorValue(record, "separator"),
|
|
257
315
|
runtimePrefix: colorValue(record, "runtimePrefix"),
|
|
258
316
|
extensionStatus: colorValue(record, "extensionStatus"),
|
|
317
|
+
sessionDuration: colorValue(record, "sessionDuration"),
|
|
318
|
+
username: colorValue(record, "username"),
|
|
319
|
+
time: colorValue(record, "time"),
|
|
320
|
+
os: colorValue(record, "os"),
|
|
259
321
|
editorAccent: colorValue(record, "editorAccent"),
|
|
260
322
|
editorPrompt: colorValue(record, "editorPrompt"),
|
|
261
323
|
editorBorder: colorValue(record, "editorBorder"),
|
|
@@ -291,10 +353,15 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
|
|
|
291
353
|
cwd: footerSegmentValue(record, "cwd"),
|
|
292
354
|
gitBranch: footerSegmentValue(record, "gitBranch"),
|
|
293
355
|
gitStatus: footerSegmentValue(record, "gitStatus"),
|
|
356
|
+
gitCounts: footerSegmentValue(record, "gitCounts"),
|
|
294
357
|
runtime: footerSegmentValue(record, "runtime"),
|
|
295
358
|
context: footerSegmentValue(record, "context"),
|
|
296
359
|
tokens: footerSegmentValue(record, "tokens"),
|
|
297
360
|
cost: footerSegmentValue(record, "cost"),
|
|
361
|
+
sessionDuration: footerSegmentValue(record, "sessionDuration"),
|
|
362
|
+
username: footerSegmentValue(record, "username"),
|
|
363
|
+
time: footerSegmentValue(record, "time"),
|
|
364
|
+
os: footerSegmentValue(record, "os"),
|
|
298
365
|
};
|
|
299
366
|
}
|
|
300
367
|
|
|
@@ -347,10 +414,15 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
|
|
|
347
414
|
value === "cwd" ||
|
|
348
415
|
value === "gitBranch" ||
|
|
349
416
|
value === "gitStatus" ||
|
|
417
|
+
value === "gitCounts" ||
|
|
350
418
|
value === "runtime" ||
|
|
351
419
|
value === "context" ||
|
|
352
420
|
value === "tokens" ||
|
|
353
|
-
value === "cost"
|
|
421
|
+
value === "cost" ||
|
|
422
|
+
value === "sessionDuration" ||
|
|
423
|
+
value === "username" ||
|
|
424
|
+
value === "time" ||
|
|
425
|
+
value === "os"
|
|
354
426
|
);
|
|
355
427
|
}
|
|
356
428
|
|
|
@@ -419,6 +491,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
|
|
|
419
491
|
: defaultConfig.extensionStatuses;
|
|
420
492
|
return {
|
|
421
493
|
projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
|
|
494
|
+
footerFormat: stringValue(config, "footerFormat") ?? "",
|
|
422
495
|
icons: {
|
|
423
496
|
...defaultConfig.icons,
|
|
424
497
|
...icons,
|
|
@@ -510,6 +583,13 @@ export function saveFooterSegmentsPatch(
|
|
|
510
583
|
return mergeConfig(record);
|
|
511
584
|
}
|
|
512
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
|
+
|
|
513
593
|
export function saveExtensionStatusPlacement(
|
|
514
594
|
key: string,
|
|
515
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
|
|
|
@@ -176,9 +185,16 @@ export function installFooter(
|
|
|
176
185
|
const gitStatusColor = (text: string) =>
|
|
177
186
|
renderStyleForSource(theme, colorSource, config.colors.gitStatus, text);
|
|
178
187
|
const gitIcon = config.icons.git ? gitColor(config.icons.git) : "";
|
|
188
|
+
const gitCounts = config.footerSegments.gitCounts;
|
|
189
|
+
const stashLabel =
|
|
190
|
+
state.stashed > 0
|
|
191
|
+
? gitCounts
|
|
192
|
+
? `${config.icons.stashed}${state.stashed}`
|
|
193
|
+
: config.icons.stashed
|
|
194
|
+
: "";
|
|
179
195
|
const allStatus = [
|
|
180
196
|
state.conflicted > 0 ? config.icons.conflicted : "",
|
|
181
|
-
|
|
197
|
+
stashLabel,
|
|
182
198
|
state.deleted > 0 ? config.icons.deleted : "",
|
|
183
199
|
state.renamed > 0 ? config.icons.renamed : "",
|
|
184
200
|
state.modified > 0 ? config.icons.modified : "",
|
|
@@ -186,16 +202,81 @@ export function installFooter(
|
|
|
186
202
|
state.staged > 0 ? config.icons.staged : "",
|
|
187
203
|
state.untracked > 0 ? config.icons.untracked : "",
|
|
188
204
|
].join("");
|
|
189
|
-
const aheadBehind =
|
|
190
|
-
state.ahead > 0 && state.behind > 0
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
205
|
+
const aheadBehind = (() => {
|
|
206
|
+
if (state.ahead > 0 && state.behind > 0) {
|
|
207
|
+
return gitCounts
|
|
208
|
+
? `${config.icons.ahead}${state.ahead}${config.icons.behind}${state.behind}`
|
|
209
|
+
: config.icons.diverged;
|
|
210
|
+
}
|
|
211
|
+
if (state.ahead > 0)
|
|
212
|
+
return gitCounts ? `${config.icons.ahead}${state.ahead}` : config.icons.ahead;
|
|
213
|
+
if (state.behind > 0)
|
|
214
|
+
return gitCounts ? `${config.icons.behind}${state.behind}` : config.icons.behind;
|
|
215
|
+
return "";
|
|
216
|
+
})();
|
|
197
217
|
const statusBlock =
|
|
198
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
|
+
};
|
|
199
280
|
const branchParts =
|
|
200
281
|
config.footerSegments.gitBranch && branch
|
|
201
282
|
? ["on", gitIcon, gitColor(branch)].filter(Boolean)
|
|
@@ -206,9 +287,53 @@ export function installFooter(
|
|
|
206
287
|
? formatRuntimeSegment(theme, state.runtime, config.colors.runtimePrefix, colorSource)
|
|
207
288
|
: "";
|
|
208
289
|
|
|
209
|
-
const
|
|
290
|
+
const sessionDurationSegment = (() => {
|
|
291
|
+
if (!config.footerSegments.sessionDuration || !state.sessionStartEpoch) return "";
|
|
292
|
+
const timeLabel = buildSessionDurationLabel(state.sessionStartEpoch);
|
|
293
|
+
const prefix = renderStyleForSource(theme, colorSource, "", "up for");
|
|
294
|
+
const time = renderStyleForSource(
|
|
295
|
+
theme,
|
|
296
|
+
colorSource,
|
|
297
|
+
config.colors.sessionDuration,
|
|
298
|
+
timeLabel,
|
|
299
|
+
);
|
|
300
|
+
return `${prefix} ${time}`;
|
|
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
|
+
: "";
|
|
318
|
+
const left = [
|
|
319
|
+
osSegment,
|
|
320
|
+
usernameSegment,
|
|
321
|
+
config.footerSegments.cwd ? cwdLabel : "",
|
|
322
|
+
branchLabel,
|
|
323
|
+
runtimeLabel,
|
|
324
|
+
sessionDurationSegment,
|
|
325
|
+
]
|
|
210
326
|
.filter(Boolean)
|
|
211
327
|
.join(" ");
|
|
328
|
+
|
|
329
|
+
const timeSegment = config.footerSegments.time
|
|
330
|
+
? renderStyleForSource(
|
|
331
|
+
theme,
|
|
332
|
+
colorSource,
|
|
333
|
+
config.colors.time,
|
|
334
|
+
formatTimeLabel(config.icons.time),
|
|
335
|
+
)
|
|
336
|
+
: "";
|
|
212
337
|
const right = [
|
|
213
338
|
config.footerSegments.context
|
|
214
339
|
? renderStyleForSource(theme, colorSource, contextColor, state.contextLabel)
|
|
@@ -219,9 +344,25 @@ export function installFooter(
|
|
|
219
344
|
config.footerSegments.cost
|
|
220
345
|
? renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel)
|
|
221
346
|
: "",
|
|
347
|
+
timeSegment,
|
|
222
348
|
]
|
|
223
349
|
.filter(Boolean)
|
|
224
350
|
.join(separator);
|
|
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
|
+
|
|
225
366
|
const extensionStatuses = collectExtensionStatusSegments(
|
|
226
367
|
footerData.getExtensionStatuses(),
|
|
227
368
|
config,
|
|
@@ -230,11 +371,15 @@ export function installFooter(
|
|
|
230
371
|
segment.colorMode === "original"
|
|
231
372
|
? segment.text
|
|
232
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;
|
|
233
378
|
const content = composeFooterContent(
|
|
234
|
-
|
|
235
|
-
|
|
379
|
+
contentLeft,
|
|
380
|
+
contentRight,
|
|
236
381
|
extensionStatuses.left.map(renderExtensionStatus),
|
|
237
|
-
|
|
382
|
+
middleSegments,
|
|
238
383
|
extensionStatuses.right.map(renderExtensionStatus),
|
|
239
384
|
separator,
|
|
240
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";
|
|
@@ -91,6 +92,16 @@ export function buildCostLabel(totals: UsageTotals): string {
|
|
|
91
92
|
return `$${totals.cost.toFixed(3)}`;
|
|
92
93
|
}
|
|
93
94
|
|
|
95
|
+
export function buildSessionDurationLabel(startEpoch: number): string {
|
|
96
|
+
const totalSeconds = Math.max(0, Math.floor((Date.now() - startEpoch) / 1000));
|
|
97
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
98
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
99
|
+
const seconds = totalSeconds % 60;
|
|
100
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
101
|
+
if (minutes > 0) return `${minutes}m ${seconds}s`;
|
|
102
|
+
return `${seconds}s`;
|
|
103
|
+
}
|
|
104
|
+
|
|
94
105
|
export function buildContextLabel(ctx: ExtensionContext): string {
|
|
95
106
|
const usage = ctx.getContextUsage();
|
|
96
107
|
const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
|
|
@@ -119,3 +130,33 @@ export function formatCwdLabel(cwd: string, cwdIcon: string): string {
|
|
|
119
130
|
const last = parts[parts.length - 1] ?? cwd;
|
|
120
131
|
return cwdIcon ? `${cwdIcon} ${last}` : last;
|
|
121
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
|
+
}
|
package/extensions/zentui/git.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type GitStatusSummary = {
|
|
|
11
11
|
behind: number;
|
|
12
12
|
conflicted: number;
|
|
13
13
|
untracked: number;
|
|
14
|
-
stashed:
|
|
14
|
+
stashed: number;
|
|
15
15
|
modified: number;
|
|
16
16
|
staged: number;
|
|
17
17
|
renamed: number;
|
|
@@ -27,7 +27,7 @@ export function emptyGitStatus(): GitStatusSummary {
|
|
|
27
27
|
behind: 0,
|
|
28
28
|
conflicted: 0,
|
|
29
29
|
untracked: 0,
|
|
30
|
-
stashed:
|
|
30
|
+
stashed: 0,
|
|
31
31
|
modified: 0,
|
|
32
32
|
staged: 0,
|
|
33
33
|
renamed: 0,
|
|
@@ -36,9 +36,9 @@ export function emptyGitStatus(): GitStatusSummary {
|
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export function parseGitStatusPorcelain(stdoutText: string,
|
|
39
|
+
export function parseGitStatusPorcelain(stdoutText: string, stashCount: number): GitStatusSummary {
|
|
40
40
|
const status = emptyGitStatus();
|
|
41
|
-
status.stashed =
|
|
41
|
+
status.stashed = stashCount;
|
|
42
42
|
|
|
43
43
|
for (const line of stdoutText.split(/\r?\n/)) {
|
|
44
44
|
if (!line) continue;
|
|
@@ -93,7 +93,7 @@ export async function readGitStatus(cwd: string): Promise<GitStatusSummary> {
|
|
|
93
93
|
cwd,
|
|
94
94
|
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
95
95
|
}),
|
|
96
|
-
execFileAsync("git", ["
|
|
96
|
+
execFileAsync("git", ["stash", "list"], {
|
|
97
97
|
cwd,
|
|
98
98
|
timeout: GIT_COMMAND_TIMEOUT_MS,
|
|
99
99
|
}).catch(() => ({ stdout: "" })),
|
|
@@ -101,7 +101,8 @@ export async function readGitStatus(cwd: string): Promise<GitStatusSummary> {
|
|
|
101
101
|
const stdoutText = typeof statusStdout === "string" ? statusStdout : String(statusStdout);
|
|
102
102
|
const stashStdout =
|
|
103
103
|
typeof stashResult.stdout === "string" ? stashResult.stdout : String(stashResult.stdout);
|
|
104
|
-
|
|
104
|
+
const stashCount = stashStdout.split(/\r?\n/).filter((line) => line.trim().length > 0).length;
|
|
105
|
+
return parseGitStatusPorcelain(stdoutText, stashCount);
|
|
105
106
|
} catch {
|
|
106
107
|
return emptyGitStatus();
|
|
107
108
|
}
|
|
@@ -16,11 +16,13 @@ import {
|
|
|
16
16
|
saveColorSourcesPatch,
|
|
17
17
|
saveExtensionStatusColorMode,
|
|
18
18
|
saveExtensionStatusPlacement,
|
|
19
|
+
saveFooterFormatPatch,
|
|
19
20
|
saveFooterSegmentsPatch,
|
|
20
21
|
saveUiFeaturesPatch,
|
|
21
22
|
type UiFeaturesConfig,
|
|
22
23
|
} from "./config";
|
|
23
24
|
import { installFooter } from "./footer";
|
|
25
|
+
import { buildSessionDurationLabel } from "./format";
|
|
24
26
|
import { emptyGitStatus, readGitStatus } from "./git";
|
|
25
27
|
import {
|
|
26
28
|
createProjectRefreshScheduler,
|
|
@@ -83,6 +85,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
83
85
|
let installedEditorFactory: EditorFactory | undefined;
|
|
84
86
|
let wrappedEditorFactory: EditorFactory | undefined;
|
|
85
87
|
let prototypePatchesInstalled = false;
|
|
88
|
+
let stopSessionTimer: () => void = () => {};
|
|
89
|
+
let lastDurationLabel = "";
|
|
86
90
|
|
|
87
91
|
const refresh = () => requestFooterRender?.();
|
|
88
92
|
const getActiveTheme = () => activeTheme;
|
|
@@ -117,6 +121,38 @@ export default function (pi: ExtensionAPI) {
|
|
|
117
121
|
projectRefreshScheduler.stop();
|
|
118
122
|
};
|
|
119
123
|
|
|
124
|
+
const startSessionTimer = () => {
|
|
125
|
+
stopSessionTimer();
|
|
126
|
+
lastDurationLabel = "";
|
|
127
|
+
const timer = setInterval(() => {
|
|
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
|
+
)
|
|
138
|
+
return;
|
|
139
|
+
if (segments.time || formatNeedsTimer) {
|
|
140
|
+
refresh();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const label = state.sessionStartEpoch
|
|
144
|
+
? buildSessionDurationLabel(state.sessionStartEpoch)
|
|
145
|
+
: "";
|
|
146
|
+
if (label === lastDurationLabel) return;
|
|
147
|
+
lastDurationLabel = label;
|
|
148
|
+
refresh();
|
|
149
|
+
}, 1000);
|
|
150
|
+
stopSessionTimer = () => {
|
|
151
|
+
clearInterval(timer);
|
|
152
|
+
stopSessionTimer = () => {};
|
|
153
|
+
};
|
|
154
|
+
};
|
|
155
|
+
|
|
120
156
|
const installPrototypePatches = () => {
|
|
121
157
|
if (prototypePatchesInstalled) return;
|
|
122
158
|
const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
|
|
@@ -239,9 +275,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
239
275
|
);
|
|
240
276
|
scheduleProjectRefresh(ctx, { force: true });
|
|
241
277
|
refresh();
|
|
278
|
+
startSessionTimer();
|
|
242
279
|
};
|
|
243
280
|
|
|
244
281
|
const uninstallStatusLine = (ctx: ExtensionContext) => {
|
|
282
|
+
stopSessionTimer();
|
|
245
283
|
stopProjectRefresh();
|
|
246
284
|
ctx.ui.setFooter(undefined);
|
|
247
285
|
footerInstalled = false;
|
|
@@ -297,6 +335,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
297
335
|
|
|
298
336
|
const cleanupUi = (ctx?: ExtensionContext) => {
|
|
299
337
|
uninstallPrototypePatches();
|
|
338
|
+
stopSessionTimer();
|
|
300
339
|
stopProjectRefresh();
|
|
301
340
|
requestFooterRender = undefined;
|
|
302
341
|
getActiveExtensionStatuses = () => new Map();
|
|
@@ -328,6 +367,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
328
367
|
};
|
|
329
368
|
|
|
330
369
|
pi.on("session_start", async (_event, ctx) => {
|
|
370
|
+
state.sessionStartEpoch = Date.now();
|
|
331
371
|
installUi(ctx);
|
|
332
372
|
scheduleEditorReconciliation(ctx);
|
|
333
373
|
});
|
|
@@ -350,6 +390,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
350
390
|
setFooterSegments(patch: Partial<FooterSegmentsConfig>) {
|
|
351
391
|
currentConfig = saveFooterSegmentsPatch(patch);
|
|
352
392
|
},
|
|
393
|
+
setFooterFormat(value: string) {
|
|
394
|
+
currentConfig = saveFooterFormatPatch(value);
|
|
395
|
+
},
|
|
353
396
|
getActiveExtensionStatuses() {
|
|
354
397
|
return getActiveExtensionStatuses();
|
|
355
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;
|
|
@@ -88,6 +89,11 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
|
|
|
88
89
|
cwd: "Current directory",
|
|
89
90
|
gitBranch: "Git branch",
|
|
90
91
|
gitStatus: "Git status",
|
|
92
|
+
gitCounts: "Git counts",
|
|
93
|
+
sessionDuration: "Session duration",
|
|
94
|
+
username: "Username@host",
|
|
95
|
+
time: "Current time",
|
|
96
|
+
os: "OS icon",
|
|
91
97
|
runtime: "Runtime",
|
|
92
98
|
context: "Context usage",
|
|
93
99
|
tokens: "Token counts",
|
|
@@ -98,6 +104,12 @@ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> =
|
|
|
98
104
|
cwd: "Show or hide the current working directory segment on the left.",
|
|
99
105
|
gitBranch: "Show or hide the git branch name on the left.",
|
|
100
106
|
gitStatus: "Show or hide git status icons and ahead/behind markers.",
|
|
107
|
+
gitCounts:
|
|
108
|
+
"Show numeric ahead/behind and stash counts (requires the Git status segment to be enabled).",
|
|
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.",
|
|
101
113
|
runtime: "Show or hide the detected runtime/language segment on the left.",
|
|
102
114
|
context: "Show or hide context usage on the right.",
|
|
103
115
|
tokens: "Show or hide input/output token counts on the right.",
|
|
@@ -114,6 +126,8 @@ const directCommandSuggestions = [
|
|
|
114
126
|
"copy-friendly enable",
|
|
115
127
|
"copy-friendly disable",
|
|
116
128
|
"copy-friendly toggle",
|
|
129
|
+
"format clear",
|
|
130
|
+
"format $cwd on $git_branch $fill $context",
|
|
117
131
|
];
|
|
118
132
|
|
|
119
133
|
const sectionLabels: Record<SettingsSection, string> = {
|
|
@@ -144,10 +158,15 @@ function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingI
|
|
|
144
158
|
value === "cwd" ||
|
|
145
159
|
value === "gitBranch" ||
|
|
146
160
|
value === "gitStatus" ||
|
|
161
|
+
value === "gitCounts" ||
|
|
162
|
+
value === "sessionDuration" ||
|
|
147
163
|
value === "runtime" ||
|
|
148
164
|
value === "context" ||
|
|
149
165
|
value === "tokens" ||
|
|
150
|
-
value === "cost"
|
|
166
|
+
value === "cost" ||
|
|
167
|
+
value === "username" ||
|
|
168
|
+
value === "time" ||
|
|
169
|
+
value === "os"
|
|
151
170
|
);
|
|
152
171
|
}
|
|
153
172
|
|
|
@@ -191,7 +210,7 @@ function footerSegmentPatch(
|
|
|
191
210
|
}
|
|
192
211
|
|
|
193
212
|
function usageText(): string {
|
|
194
|
-
return
|
|
213
|
+
return 'Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle] or /zentui format "<template>"';
|
|
195
214
|
}
|
|
196
215
|
|
|
197
216
|
function featureNotification(
|
|
@@ -235,6 +254,18 @@ function parseDirectFeatureCommand(
|
|
|
235
254
|
};
|
|
236
255
|
}
|
|
237
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
|
+
|
|
238
269
|
function argumentCompletions(prefix: string): AutocompleteItem[] | null {
|
|
239
270
|
const trimmedPrefix = prefix.trimStart().toLowerCase();
|
|
240
271
|
const items = directCommandSuggestions.map((value) => ({ value, label: value }));
|
|
@@ -376,6 +407,26 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
|
|
|
376
407
|
getArgumentCompletions: argumentCompletions,
|
|
377
408
|
handler: async (_args, ctx) => {
|
|
378
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
|
+
|
|
379
430
|
const directCommand = parseDirectFeatureCommand(args, deps.getConfig());
|
|
380
431
|
if (directCommand) {
|
|
381
432
|
try {
|
|
@@ -16,6 +16,7 @@ export type FooterState = GitStatusSummary & {
|
|
|
16
16
|
tokenLabel: string;
|
|
17
17
|
costLabel: string;
|
|
18
18
|
runtime?: RuntimeInfo;
|
|
19
|
+
sessionStartEpoch?: number;
|
|
19
20
|
};
|
|
20
21
|
|
|
21
22
|
export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
@@ -26,6 +27,7 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
|
|
|
26
27
|
tokenLabel: "↑0 ↓0",
|
|
27
28
|
costLabel: "$0.000",
|
|
28
29
|
runtime: undefined,
|
|
30
|
+
sessionStartEpoch: Date.now(),
|
|
29
31
|
...gitDefaults,
|
|
30
32
|
};
|
|
31
33
|
}
|