pi-zentui 0.3.1 → 0.3.3

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
@@ -163,7 +163,8 @@ Default config values — copy this and change any value you want:
163
163
  "deleted": "✘",
164
164
  "typechanged": "T",
165
165
  "cacheHit": "󰆼",
166
- "editorPrompt": ""
166
+ "editorPrompt": "",
167
+ "rail": "│"
167
168
  },
168
169
  "colors": {
169
170
  "cwd": "bold cyan",
@@ -218,7 +219,7 @@ Default config values — copy this and change any value you want:
218
219
 
219
220
  - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
220
221
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
221
- - `icons`: every shown icon key is configurable; omit any key to use the Zentui default. `editorPrompt` controls an optional copy-friendly editor prompt glyph; the default is `""` so copy-friendly mode stays rail-free.
222
+ - `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.
222
223
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
223
224
  - `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.
224
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`.
@@ -22,10 +22,12 @@ 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;
29
31
  };
30
32
 
31
33
  export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
@@ -60,6 +62,7 @@ export type PolishedTuiConfig = {
60
62
  typechanged: string;
61
63
  cacheHit: string;
62
64
  editorPrompt: string;
65
+ rail: string;
63
66
  };
64
67
  colors: {
65
68
  cwd: ColorSpec;
@@ -73,6 +76,7 @@ export type PolishedTuiConfig = {
73
76
  separator: ColorSpec;
74
77
  runtimePrefix: ColorSpec;
75
78
  extensionStatus: ColorSpec;
79
+ sessionDuration: ColorSpec;
76
80
  editorAccent?: ColorSpec;
77
81
  editorPrompt?: ColorSpec;
78
82
  editorBorder?: ColorSpec;
@@ -111,6 +115,7 @@ export const defaultConfig: PolishedTuiConfig = {
111
115
  typechanged: "T",
112
116
  cacheHit: "󰆼",
113
117
  editorPrompt: "",
118
+ rail: "│",
114
119
  },
115
120
  colors: {
116
121
  cwd: "bold cyan",
@@ -124,6 +129,7 @@ export const defaultConfig: PolishedTuiConfig = {
124
129
  separator: "bright-black",
125
130
  runtimePrefix: "",
126
131
  extensionStatus: "bright-black",
132
+ sessionDuration: "yellow",
127
133
  },
128
134
  colorSources: {
129
135
  starship: "theme",
@@ -139,10 +145,12 @@ export const defaultConfig: PolishedTuiConfig = {
139
145
  cwd: true,
140
146
  gitBranch: true,
141
147
  gitStatus: true,
148
+ gitCounts: false,
142
149
  runtime: true,
143
150
  context: true,
144
151
  tokens: true,
145
152
  cost: true,
153
+ sessionDuration: false,
146
154
  },
147
155
  extensionStatuses: {
148
156
  defaultPlacement: "right",
@@ -187,6 +195,10 @@ function parseProjectRefreshIntervalMs(value: unknown): number {
187
195
  : defaultConfig.projectRefreshIntervalMs;
188
196
  }
189
197
 
198
+ function railValue(value: unknown): string {
199
+ return typeof value === "string" && value.trim().length > 0 ? value : defaultConfig.icons.rail;
200
+ }
201
+
190
202
  function stringValue(record: Record<string, unknown>, key: string): string | undefined {
191
203
  const value = record[key];
192
204
  return typeof value === "string" ? value : undefined;
@@ -250,6 +262,7 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
250
262
  separator: colorValue(record, "separator"),
251
263
  runtimePrefix: colorValue(record, "runtimePrefix"),
252
264
  extensionStatus: colorValue(record, "extensionStatus"),
265
+ sessionDuration: colorValue(record, "sessionDuration"),
253
266
  editorAccent: colorValue(record, "editorAccent"),
254
267
  editorPrompt: colorValue(record, "editorPrompt"),
255
268
  editorBorder: colorValue(record, "editorBorder"),
@@ -285,10 +298,12 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
285
298
  cwd: footerSegmentValue(record, "cwd"),
286
299
  gitBranch: footerSegmentValue(record, "gitBranch"),
287
300
  gitStatus: footerSegmentValue(record, "gitStatus"),
301
+ gitCounts: footerSegmentValue(record, "gitCounts"),
288
302
  runtime: footerSegmentValue(record, "runtime"),
289
303
  context: footerSegmentValue(record, "context"),
290
304
  tokens: footerSegmentValue(record, "tokens"),
291
305
  cost: footerSegmentValue(record, "cost"),
306
+ sessionDuration: footerSegmentValue(record, "sessionDuration"),
292
307
  };
293
308
  }
294
309
 
@@ -341,10 +356,12 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
341
356
  value === "cwd" ||
342
357
  value === "gitBranch" ||
343
358
  value === "gitStatus" ||
359
+ value === "gitCounts" ||
344
360
  value === "runtime" ||
345
361
  value === "context" ||
346
362
  value === "tokens" ||
347
- value === "cost"
363
+ value === "cost" ||
364
+ value === "sessionDuration"
348
365
  );
349
366
  }
350
367
 
@@ -394,9 +411,8 @@ export function ensureConfigExists(): void {
394
411
 
395
412
  export function mergeConfig(parsed: unknown): PolishedTuiConfig {
396
413
  const config = isRecord(parsed) ? parsed : {};
397
- const icons = isRecord(config.icons)
398
- ? normalizeIcons(config.icons as Record<string, unknown>)
399
- : {};
414
+ const iconsRecord = isRecord(config.icons) ? (config.icons as Record<string, unknown>) : {};
415
+ const icons = normalizeIcons(iconsRecord);
400
416
  const colors = isRecord(config.colors)
401
417
  ? normalizeColors(config.colors as Record<string, unknown>)
402
418
  : {};
@@ -417,6 +433,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
417
433
  icons: {
418
434
  ...defaultConfig.icons,
419
435
  ...icons,
436
+ rail: railValue(iconsRecord.rail),
420
437
  },
421
438
  colors: {
422
439
  ...defaultConfig.colors,
@@ -2,7 +2,7 @@ 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
4
  import { collectExtensionStatusSegments, type ExtensionStatusSegment } from "./extension-status";
5
- import { formatCwdLabel, formatRuntimeSegment } from "./format";
5
+ import { buildSessionDurationLabel, formatCwdLabel, formatRuntimeSegment } from "./format";
6
6
  import type { FooterState } from "./state";
7
7
  import { renderStyleForSource } from "./style";
8
8
 
@@ -176,9 +176,16 @@ export function installFooter(
176
176
  const gitStatusColor = (text: string) =>
177
177
  renderStyleForSource(theme, colorSource, config.colors.gitStatus, text);
178
178
  const gitIcon = config.icons.git ? gitColor(config.icons.git) : "";
179
+ const gitCounts = config.footerSegments.gitCounts;
180
+ const stashLabel =
181
+ state.stashed > 0
182
+ ? gitCounts
183
+ ? `${config.icons.stashed}${state.stashed}`
184
+ : config.icons.stashed
185
+ : "";
179
186
  const allStatus = [
180
187
  state.conflicted > 0 ? config.icons.conflicted : "",
181
- state.stashed ? config.icons.stashed : "",
188
+ stashLabel,
182
189
  state.deleted > 0 ? config.icons.deleted : "",
183
190
  state.renamed > 0 ? config.icons.renamed : "",
184
191
  state.modified > 0 ? config.icons.modified : "",
@@ -186,14 +193,18 @@ export function installFooter(
186
193
  state.staged > 0 ? config.icons.staged : "",
187
194
  state.untracked > 0 ? config.icons.untracked : "",
188
195
  ].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
- : "";
196
+ const aheadBehind = (() => {
197
+ if (state.ahead > 0 && state.behind > 0) {
198
+ return gitCounts
199
+ ? `${config.icons.ahead}${state.ahead}${config.icons.behind}${state.behind}`
200
+ : config.icons.diverged;
201
+ }
202
+ if (state.ahead > 0)
203
+ return gitCounts ? `${config.icons.ahead}${state.ahead}` : config.icons.ahead;
204
+ if (state.behind > 0)
205
+ return gitCounts ? `${config.icons.behind}${state.behind}` : config.icons.behind;
206
+ return "";
207
+ })();
197
208
  const statusBlock =
198
209
  allStatus || aheadBehind ? gitStatusColor(`[${allStatus}${aheadBehind}]`) : "";
199
210
  const branchParts =
@@ -206,9 +217,27 @@ export function installFooter(
206
217
  ? formatRuntimeSegment(theme, state.runtime, config.colors.runtimePrefix, colorSource)
207
218
  : "";
208
219
 
209
- const left = [config.footerSegments.cwd ? cwdLabel : "", branchLabel, runtimeLabel]
220
+ const sessionDurationSegment = (() => {
221
+ if (!config.footerSegments.sessionDuration || !state.sessionStartEpoch) return "";
222
+ const timeLabel = buildSessionDurationLabel(state.sessionStartEpoch);
223
+ const prefix = renderStyleForSource(theme, colorSource, "", "up for");
224
+ const time = renderStyleForSource(
225
+ theme,
226
+ colorSource,
227
+ config.colors.sessionDuration,
228
+ timeLabel,
229
+ );
230
+ return `${prefix} ${time}`;
231
+ })();
232
+ const left = [
233
+ config.footerSegments.cwd ? cwdLabel : "",
234
+ branchLabel,
235
+ runtimeLabel,
236
+ sessionDurationSegment,
237
+ ]
210
238
  .filter(Boolean)
211
239
  .join(" ");
240
+
212
241
  const right = [
213
242
  config.footerSegments.context
214
243
  ? renderStyleForSource(theme, colorSource, contextColor, state.contextLabel)
@@ -222,6 +251,7 @@ export function installFooter(
222
251
  ]
223
252
  .filter(Boolean)
224
253
  .join(separator);
254
+
225
255
  const extensionStatuses = collectExtensionStatusSegments(
226
256
  footerData.getExtensionStatuses(),
227
257
  config,
@@ -91,6 +91,16 @@ export function buildCostLabel(totals: UsageTotals): string {
91
91
  return `$${totals.cost.toFixed(3)}`;
92
92
  }
93
93
 
94
+ export function buildSessionDurationLabel(startEpoch: number): string {
95
+ const totalSeconds = Math.max(0, Math.floor((Date.now() - startEpoch) / 1000));
96
+ const hours = Math.floor(totalSeconds / 3600);
97
+ const minutes = Math.floor((totalSeconds % 3600) / 60);
98
+ const seconds = totalSeconds % 60;
99
+ if (hours > 0) return `${hours}h ${minutes}m`;
100
+ if (minutes > 0) return `${minutes}m ${seconds}s`;
101
+ return `${seconds}s`;
102
+ }
103
+
94
104
  export function buildContextLabel(ctx: ExtensionContext): string {
95
105
  const usage = ctx.getContextUsage();
96
106
  const contextWindow = ctx.model?.contextWindow ?? usage?.contextWindow;
@@ -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
  }
@@ -21,6 +21,7 @@ import {
21
21
  type UiFeaturesConfig,
22
22
  } from "./config";
23
23
  import { installFooter } from "./footer";
24
+ import { buildSessionDurationLabel } from "./format";
24
25
  import { emptyGitStatus, readGitStatus } from "./git";
25
26
  import {
26
27
  createProjectRefreshScheduler,
@@ -83,6 +84,8 @@ export default function (pi: ExtensionAPI) {
83
84
  let installedEditorFactory: EditorFactory | undefined;
84
85
  let wrappedEditorFactory: EditorFactory | undefined;
85
86
  let prototypePatchesInstalled = false;
87
+ let stopSessionTimer: () => void = () => {};
88
+ let lastDurationLabel = "";
86
89
 
87
90
  const refresh = () => requestFooterRender?.();
88
91
  const getActiveTheme = () => activeTheme;
@@ -117,6 +120,25 @@ export default function (pi: ExtensionAPI) {
117
120
  projectRefreshScheduler.stop();
118
121
  };
119
122
 
123
+ const startSessionTimer = () => {
124
+ stopSessionTimer();
125
+ lastDurationLabel = "";
126
+ const timer = setInterval(() => {
127
+ if (!(currentConfig.features.statusLine && currentConfig.footerSegments.sessionDuration))
128
+ return;
129
+ const label = state.sessionStartEpoch
130
+ ? buildSessionDurationLabel(state.sessionStartEpoch)
131
+ : "";
132
+ if (label === lastDurationLabel) return;
133
+ lastDurationLabel = label;
134
+ refresh();
135
+ }, 1000);
136
+ stopSessionTimer = () => {
137
+ clearInterval(timer);
138
+ stopSessionTimer = () => {};
139
+ };
140
+ };
141
+
120
142
  const installPrototypePatches = () => {
121
143
  if (prototypePatchesInstalled) return;
122
144
  const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
@@ -239,9 +261,11 @@ export default function (pi: ExtensionAPI) {
239
261
  );
240
262
  scheduleProjectRefresh(ctx, { force: true });
241
263
  refresh();
264
+ startSessionTimer();
242
265
  };
243
266
 
244
267
  const uninstallStatusLine = (ctx: ExtensionContext) => {
268
+ stopSessionTimer();
245
269
  stopProjectRefresh();
246
270
  ctx.ui.setFooter(undefined);
247
271
  footerInstalled = false;
@@ -297,6 +321,7 @@ export default function (pi: ExtensionAPI) {
297
321
 
298
322
  const cleanupUi = (ctx?: ExtensionContext) => {
299
323
  uninstallPrototypePatches();
324
+ stopSessionTimer();
300
325
  stopProjectRefresh();
301
326
  requestFooterRender = undefined;
302
327
  getActiveExtensionStatuses = () => new Map();
@@ -328,6 +353,7 @@ export default function (pi: ExtensionAPI) {
328
353
  };
329
354
 
330
355
  pi.on("session_start", async (_event, ctx) => {
356
+ state.sessionStartEpoch = Date.now();
331
357
  installUi(ctx);
332
358
  scheduleEditorReconciliation(ctx);
333
359
  });
@@ -88,6 +88,8 @@ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
88
88
  cwd: "Current directory",
89
89
  gitBranch: "Git branch",
90
90
  gitStatus: "Git status",
91
+ gitCounts: "Git counts",
92
+ sessionDuration: "Session duration",
91
93
  runtime: "Runtime",
92
94
  context: "Context usage",
93
95
  tokens: "Token counts",
@@ -98,6 +100,9 @@ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> =
98
100
  cwd: "Show or hide the current working directory segment on the left.",
99
101
  gitBranch: "Show or hide the git branch name on the left.",
100
102
  gitStatus: "Show or hide git status icons and ahead/behind markers.",
103
+ gitCounts:
104
+ "Show numeric ahead/behind and stash counts (requires the Git status segment to be enabled).",
105
+ sessionDuration: "Show session running time on the left, after the runtime.",
101
106
  runtime: "Show or hide the detected runtime/language segment on the left.",
102
107
  context: "Show or hide context usage on the right.",
103
108
  tokens: "Show or hide input/output token counts on the right.",
@@ -144,6 +149,8 @@ function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingI
144
149
  value === "cwd" ||
145
150
  value === "gitBranch" ||
146
151
  value === "gitStatus" ||
152
+ value === "gitCounts" ||
153
+ value === "sessionDuration" ||
147
154
  value === "runtime" ||
148
155
  value === "context" ||
149
156
  value === "tokens" ||
@@ -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
  }
@@ -111,7 +111,7 @@ function getEditorChromeWidths(config: PolishedTuiConfig, uiTheme: Theme, reset:
111
111
  config.colorSources.editor,
112
112
  config.colors.editorAccent,
113
113
  EDITOR_ACCENT_FALLBACK,
114
- "│",
114
+ config.icons.rail,
115
115
  )}${reset} `;
116
116
  return {
117
117
  prompt,
@@ -87,6 +87,7 @@ function getUserMessageConfigKey(config: PolishedTuiConfig): string {
87
87
  config.colorSources.userMessages,
88
88
  config.colors.editorAccent ?? "",
89
89
  config.colors.editorBorder ?? "",
90
+ config.icons.rail,
90
91
  ].join("\0");
91
92
  }
92
93
 
@@ -126,6 +127,7 @@ function fillLine(content: string, width: number): string {
126
127
 
127
128
  function renderPromptBoxRail(theme: Theme | undefined, config: PolishedTuiConfig): string {
128
129
  if (config.features.copyFriendly) return "";
130
+ const railGlyph = config.icons.rail;
129
131
 
130
132
  return `${
131
133
  theme
@@ -134,9 +136,9 @@ function renderPromptBoxRail(theme: Theme | undefined, config: PolishedTuiConfig
134
136
  config.colorSources.userMessages,
135
137
  config.colors.editorAccent,
136
138
  EDITOR_ACCENT_FALLBACK,
137
- "│",
139
+ railGlyph,
138
140
  )
139
- : "│"
141
+ : railGlyph
140
142
  } `;
141
143
  }
142
144
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",