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 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 { buildSessionDurationLabel, 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
 
@@ -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
- left,
265
- right,
379
+ contentLeft,
380
+ contentRight,
266
381
  extensionStatuses.left.map(renderExtensionStatus),
267
- extensionStatuses.middle.map(renderExtensionStatus),
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
- if (!(currentConfig.features.statusLine && currentConfig.footerSegments.sessionDuration))
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 "Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle]";
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.3.3",
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",