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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025
3
+ Copyright (c) 2025-2026 Luka
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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 { formatCwdLabel, formatRuntimeSegment } from "./format";
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
- state.stashed ? config.icons.stashed : "",
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
- ? config.icons.diverged
192
- : state.ahead > 0
193
- ? config.icons.ahead
194
- : state.behind > 0
195
- ? config.icons.behind
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 left = [config.footerSegments.cwd ? cwdLabel : "", branchLabel, runtimeLabel]
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
- left,
235
- right,
379
+ contentLeft,
380
+ contentRight,
236
381
  extensionStatuses.left.map(renderExtensionStatus),
237
- extensionStatuses.middle.map(renderExtensionStatus),
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
+ }
@@ -11,7 +11,7 @@ export type GitStatusSummary = {
11
11
  behind: number;
12
12
  conflicted: number;
13
13
  untracked: number;
14
- stashed: boolean;
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: false,
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, hasStash: boolean): GitStatusSummary {
39
+ export function parseGitStatusPorcelain(stdoutText: string, stashCount: number): GitStatusSummary {
40
40
  const status = emptyGitStatus();
41
- status.stashed = hasStash;
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", ["rev-parse", "--verify", "--quiet", "refs/stash"], {
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
- return parseGitStatusPorcelain(stdoutText, stashStdout.trim().length > 0);
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 "Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle]";
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",