pi-zentui 0.3.2 → 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
@@ -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";
@@ -74,6 +76,7 @@ export type PolishedTuiConfig = {
74
76
  separator: ColorSpec;
75
77
  runtimePrefix: ColorSpec;
76
78
  extensionStatus: ColorSpec;
79
+ sessionDuration: ColorSpec;
77
80
  editorAccent?: ColorSpec;
78
81
  editorPrompt?: ColorSpec;
79
82
  editorBorder?: ColorSpec;
@@ -126,6 +129,7 @@ export const defaultConfig: PolishedTuiConfig = {
126
129
  separator: "bright-black",
127
130
  runtimePrefix: "",
128
131
  extensionStatus: "bright-black",
132
+ sessionDuration: "yellow",
129
133
  },
130
134
  colorSources: {
131
135
  starship: "theme",
@@ -141,10 +145,12 @@ export const defaultConfig: PolishedTuiConfig = {
141
145
  cwd: true,
142
146
  gitBranch: true,
143
147
  gitStatus: true,
148
+ gitCounts: false,
144
149
  runtime: true,
145
150
  context: true,
146
151
  tokens: true,
147
152
  cost: true,
153
+ sessionDuration: false,
148
154
  },
149
155
  extensionStatuses: {
150
156
  defaultPlacement: "right",
@@ -256,6 +262,7 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
256
262
  separator: colorValue(record, "separator"),
257
263
  runtimePrefix: colorValue(record, "runtimePrefix"),
258
264
  extensionStatus: colorValue(record, "extensionStatus"),
265
+ sessionDuration: colorValue(record, "sessionDuration"),
259
266
  editorAccent: colorValue(record, "editorAccent"),
260
267
  editorPrompt: colorValue(record, "editorPrompt"),
261
268
  editorBorder: colorValue(record, "editorBorder"),
@@ -291,10 +298,12 @@ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegment
291
298
  cwd: footerSegmentValue(record, "cwd"),
292
299
  gitBranch: footerSegmentValue(record, "gitBranch"),
293
300
  gitStatus: footerSegmentValue(record, "gitStatus"),
301
+ gitCounts: footerSegmentValue(record, "gitCounts"),
294
302
  runtime: footerSegmentValue(record, "runtime"),
295
303
  context: footerSegmentValue(record, "context"),
296
304
  tokens: footerSegmentValue(record, "tokens"),
297
305
  cost: footerSegmentValue(record, "cost"),
306
+ sessionDuration: footerSegmentValue(record, "sessionDuration"),
298
307
  };
299
308
  }
300
309
 
@@ -347,10 +356,12 @@ function isFooterSegmentKey(value: string): value is keyof FooterSegmentsConfig
347
356
  value === "cwd" ||
348
357
  value === "gitBranch" ||
349
358
  value === "gitStatus" ||
359
+ value === "gitCounts" ||
350
360
  value === "runtime" ||
351
361
  value === "context" ||
352
362
  value === "tokens" ||
353
- value === "cost"
363
+ value === "cost" ||
364
+ value === "sessionDuration"
354
365
  );
355
366
  }
356
367
 
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.3.2",
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",