pi-zentui 0.1.11 → 0.1.13

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
@@ -22,6 +22,8 @@ Zentui brings two popular aesthetics to Pi:
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
24
  - Right side shows context usage, token counts, and cost
25
+ - Third-party Pi extension statuses from `ctx.ui.setStatus()` can be shown on the left,
26
+ middle, or right side, or hidden per status key from `/zentui`
25
27
 
26
28
  ### Editor (Opencode-inspired)
27
29
 
@@ -121,7 +123,7 @@ pi install git:github.com/lmilojevicc/pi-zentui
121
123
 
122
124
  ## Config
123
125
 
124
- User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` currently patches only color-source settings.
126
+ User config lives at `~/.pi/agent/zentui.json`. The file is optional: missing or invalid known values fall back to Zentui defaults, unknown keys are ignored at runtime, and `/zentui` can patch color-source settings plus active third-party status placements.
125
127
 
126
128
  Default config values — copy this and change any value you want:
127
129
 
@@ -152,6 +154,7 @@ Default config values — copy this and change any value you want:
152
154
  "contextError": "bold red",
153
155
  "tokens": "bright-black",
154
156
  "cost": "bold green",
157
+ "extensionStatus": "bright-black",
155
158
  "separator": "bright-black",
156
159
  "runtimePrefix": "",
157
160
  "editorAccent": "accent",
@@ -169,6 +172,10 @@ Default config values — copy this and change any value you want:
169
172
  "starship": "theme",
170
173
  "editor": "theme",
171
174
  "userMessages": "theme"
175
+ },
176
+ "extensionStatuses": {
177
+ "defaultPlacement": "right",
178
+ "placements": {}
172
179
  }
173
180
  }
174
181
  ```
@@ -177,6 +184,7 @@ Default config values — copy this and change any value you want:
177
184
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
178
185
  - `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
179
186
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
187
+ - `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`. `/zentui` lists only statuses that are currently active.
180
188
  - 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`.
181
189
  - `editorAccent` styles the active editor rail and previous user-message rail.
182
190
  - `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
@@ -12,6 +12,13 @@ export type ColorSourcesConfig = {
12
12
  userMessages: ColorSource;
13
13
  };
14
14
 
15
+ export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
16
+
17
+ export type ExtensionStatusesConfig = {
18
+ defaultPlacement: ExtensionStatusPlacement;
19
+ placements: Record<string, ExtensionStatusPlacement>;
20
+ };
21
+
15
22
  const DEFAULT_PROJECT_REFRESH_INTERVAL_MS = 30_000;
16
23
  const MIN_PROJECT_REFRESH_INTERVAL_MS = 5_000;
17
24
 
@@ -43,6 +50,7 @@ export type PolishedTuiConfig = {
43
50
  cost: ColorSpec;
44
51
  separator: ColorSpec;
45
52
  runtimePrefix: ColorSpec;
53
+ extensionStatus: ColorSpec;
46
54
  editorAccent?: ColorSpec;
47
55
  editorBorder?: ColorSpec;
48
56
  editorModel?: ColorSpec;
@@ -55,6 +63,7 @@ export type PolishedTuiConfig = {
55
63
  editorThinkingXhigh?: ColorSpec;
56
64
  };
57
65
  colorSources: ColorSourcesConfig;
66
+ extensionStatuses: ExtensionStatusesConfig;
58
67
  };
59
68
 
60
69
  export const configPath = join(getAgentDir(), "zentui.json");
@@ -87,12 +96,17 @@ export const defaultConfig: PolishedTuiConfig = {
87
96
  cost: "bold green",
88
97
  separator: "bright-black",
89
98
  runtimePrefix: "",
99
+ extensionStatus: "bright-black",
90
100
  },
91
101
  colorSources: {
92
102
  starship: "theme",
93
103
  editor: "theme",
94
104
  userMessages: "theme",
95
105
  },
106
+ extensionStatuses: {
107
+ defaultPlacement: "right",
108
+ placements: {},
109
+ },
96
110
  };
97
111
 
98
112
  const iconKeys = [
@@ -178,6 +192,7 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
178
192
  cost: colorValue(record, "cost"),
179
193
  separator: colorValue(record, "separator"),
180
194
  runtimePrefix: colorValue(record, "runtimePrefix"),
195
+ extensionStatus: colorValue(record, "extensionStatus"),
181
196
  editorAccent: colorValue(record, "editorAccent"),
182
197
  editorBorder: colorValue(record, "editorBorder"),
183
198
  editorModel: colorValue(record, "editorModel"),
@@ -199,6 +214,29 @@ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesCon
199
214
  };
200
215
  }
201
216
 
217
+ export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
218
+ return value === "off" || value === "left" || value === "middle" || value === "right";
219
+ }
220
+
221
+ function normalizeExtensionStatuses(record: Record<string, unknown>): ExtensionStatusesConfig {
222
+ const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
223
+ ? record.defaultPlacement
224
+ : defaultConfig.extensionStatuses.defaultPlacement;
225
+ const placements = isRecord(record.placements)
226
+ ? Object.fromEntries(
227
+ Object.entries(record.placements).filter(
228
+ (entry): entry is [string, ExtensionStatusPlacement] =>
229
+ isExtensionStatusPlacement(entry[1]),
230
+ ),
231
+ )
232
+ : {};
233
+
234
+ return {
235
+ defaultPlacement,
236
+ placements,
237
+ };
238
+ }
239
+
202
240
  function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
203
241
  return value === "starship" || value === "editor" || value === "userMessages";
204
242
  }
@@ -240,6 +278,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
240
278
  const colorSources = isRecord(config.colorSources)
241
279
  ? normalizeColorSources(config.colorSources as Record<string, unknown>)
242
280
  : defaultConfig.colorSources;
281
+ const extensionStatuses = isRecord(config.extensionStatuses)
282
+ ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>)
283
+ : defaultConfig.extensionStatuses;
243
284
  return {
244
285
  projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
245
286
  icons: {
@@ -251,9 +292,20 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
251
292
  ...colors,
252
293
  },
253
294
  colorSources: { ...colorSources },
295
+ extensionStatuses: {
296
+ defaultPlacement: extensionStatuses.defaultPlacement,
297
+ placements: { ...extensionStatuses.placements },
298
+ },
254
299
  };
255
300
  }
256
301
 
302
+ export function getExtensionStatusPlacement(
303
+ config: PolishedTuiConfig,
304
+ key: string,
305
+ ): ExtensionStatusPlacement {
306
+ return config.extensionStatuses.placements[key] ?? config.extensionStatuses.defaultPlacement;
307
+ }
308
+
257
309
  export function loadConfig(): PolishedTuiConfig {
258
310
  try {
259
311
  if (!existsSync(configPath)) return mergeConfig({});
@@ -278,3 +330,31 @@ export function saveColorSourcesPatch(
278
330
  writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
279
331
  return mergeConfig(record);
280
332
  }
333
+
334
+ export function saveExtensionStatusPlacement(
335
+ key: string,
336
+ placement: ExtensionStatusPlacement,
337
+ path = configPath,
338
+ ): PolishedTuiConfig {
339
+ const record = readConfigRecord(path);
340
+ const existingExtensionStatuses = isRecord(record.extensionStatuses)
341
+ ? { ...(record.extensionStatuses as Record<string, unknown>) }
342
+ : {};
343
+ const existingPlacements = isRecord(existingExtensionStatuses.placements)
344
+ ? { ...(existingExtensionStatuses.placements as Record<string, unknown>) }
345
+ : {};
346
+
347
+ Object.defineProperty(existingPlacements, key, {
348
+ value: placement,
349
+ enumerable: true,
350
+ configurable: true,
351
+ writable: true,
352
+ });
353
+
354
+ record.extensionStatuses = {
355
+ ...existingExtensionStatuses,
356
+ placements: existingPlacements,
357
+ };
358
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
359
+ return mergeConfig(record);
360
+ }
@@ -0,0 +1,53 @@
1
+ import { stripVTControlCharacters } from "node:util";
2
+ import type { ExtensionStatusPlacement, PolishedTuiConfig } from "./config";
3
+ import { getExtensionStatusPlacement } from "./config";
4
+
5
+ export type ExtensionStatusSegment = {
6
+ key: string;
7
+ text: string;
8
+ placement: ExtensionStatusPlacement;
9
+ };
10
+
11
+ export type ExtensionStatusSegmentsByPlacement = {
12
+ left: ExtensionStatusSegment[];
13
+ middle: ExtensionStatusSegment[];
14
+ right: ExtensionStatusSegment[];
15
+ };
16
+
17
+ function compareKeys(a: ExtensionStatusSegment, b: ExtensionStatusSegment): number {
18
+ return a.key < b.key ? -1 : a.key > b.key ? 1 : 0;
19
+ }
20
+
21
+ export function sanitizeExtensionStatusText(value: string): string {
22
+ return stripVTControlCharacters(value)
23
+ .replace(/[\r\n\t\f\v]+/g, " ")
24
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ }
28
+
29
+ export function collectExtensionStatusSegments(
30
+ statuses: ReadonlyMap<string, string>,
31
+ config: PolishedTuiConfig,
32
+ ): ExtensionStatusSegmentsByPlacement {
33
+ const segments: ExtensionStatusSegmentsByPlacement = {
34
+ left: [],
35
+ middle: [],
36
+ right: [],
37
+ };
38
+
39
+ for (const [key, value] of statuses.entries()) {
40
+ const placement = getExtensionStatusPlacement(config, key);
41
+ if (placement === "off") continue;
42
+
43
+ const text = sanitizeExtensionStatusText(value);
44
+ if (!text) continue;
45
+
46
+ segments[placement].push({ key, text, placement });
47
+ }
48
+
49
+ segments.left.sort(compareKeys);
50
+ segments.middle.sort(compareKeys);
51
+ segments.right.sort(compareKeys);
52
+ return segments;
53
+ }
@@ -1,10 +1,129 @@
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 { collectExtensionStatusSegments } from "./extension-status";
4
5
  import { formatCwdLabel, formatRuntimeSegment } from "./format";
5
6
  import type { FooterState } from "./state";
6
7
  import { renderStyleForSource } from "./style";
7
8
 
9
+ function joinStatusTexts(statusTexts: string[], separator: string): string {
10
+ return statusTexts.filter(Boolean).join(separator);
11
+ }
12
+
13
+ function fitStatusTexts(statusTexts: string[], maxWidth: number, separator: string): string {
14
+ if (maxWidth <= 0) return "";
15
+
16
+ const fitted: string[] = [];
17
+ for (const text of statusTexts) {
18
+ const candidate = joinStatusTexts([...fitted, text], separator);
19
+ if (visibleWidth(candidate) <= maxWidth) {
20
+ fitted.push(text);
21
+ continue;
22
+ }
23
+
24
+ if (fitted.length === 0) {
25
+ return maxWidth > 1 ? truncateToWidth(text, maxWidth, "…") : "";
26
+ }
27
+ break;
28
+ }
29
+
30
+ return joinStatusTexts(fitted, separator);
31
+ }
32
+
33
+ function appendStatusArea(base: string, statusText: string, separator: string): string {
34
+ if (!base) return statusText;
35
+ if (!statusText) return base;
36
+ return `${base}${separator}${statusText}`;
37
+ }
38
+
39
+ function prependStatusArea(base: string, statusText: string, separator: string): string {
40
+ if (!base) return statusText;
41
+ if (!statusText) return base;
42
+ return `${statusText}${separator}${base}`;
43
+ }
44
+
45
+ function composeBuiltInFooterContent(left: string, right: string, innerWidth: number): string {
46
+ const leftWidth = visibleWidth(left);
47
+ const rightWidth = visibleWidth(right);
48
+ return leftWidth >= innerWidth
49
+ ? truncateToWidth(left, innerWidth, "")
50
+ : leftWidth + 1 + rightWidth <= innerWidth
51
+ ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
52
+ : truncateToWidth(left, innerWidth, "");
53
+ }
54
+
55
+ function composeFooterContent(
56
+ builtInLeft: string,
57
+ builtInRight: string,
58
+ extensionLeft: string[],
59
+ extensionMiddle: string[],
60
+ extensionRight: string[],
61
+ separator: string,
62
+ innerWidth: number,
63
+ ): string {
64
+ const builtInLeftWidth = visibleWidth(builtInLeft);
65
+ const builtInRightWidth = visibleWidth(builtInRight);
66
+ const minimumGap = builtInLeft && builtInRight ? 1 : 0;
67
+
68
+ if (builtInLeftWidth + minimumGap + builtInRightWidth > innerWidth) {
69
+ return composeBuiltInFooterContent(builtInLeft, builtInRight, innerWidth);
70
+ }
71
+
72
+ const available = Math.max(0, innerWidth - builtInLeftWidth - builtInRightWidth - minimumGap);
73
+ let remaining = available;
74
+ const leftConnectorWidth = builtInLeft && extensionLeft.length > 0 ? visibleWidth(separator) : 0;
75
+ const rightConnectorWidth =
76
+ builtInRight && extensionRight.length > 0 ? visibleWidth(separator) : 0;
77
+ let leftStatus = "";
78
+ let rightStatus = "";
79
+
80
+ if (extensionLeft.length > 0 && extensionRight.length > 0) {
81
+ const leftBudget = Math.max(0, Math.floor(available / 2) - leftConnectorWidth);
82
+ leftStatus = fitStatusTexts(extensionLeft, leftBudget, separator);
83
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
84
+
85
+ const rightBudget = Math.max(0, remaining - rightConnectorWidth);
86
+ rightStatus = fitStatusTexts(extensionRight, rightBudget, separator);
87
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
88
+
89
+ const expandedLeftBudget = Math.max(0, remaining + visibleWidth(leftStatus));
90
+ const expandedLeftStatus = fitStatusTexts(extensionLeft, expandedLeftBudget, separator);
91
+ if (visibleWidth(expandedLeftStatus) > visibleWidth(leftStatus)) {
92
+ remaining += leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
93
+ leftStatus = expandedLeftStatus;
94
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
95
+ }
96
+ } else if (extensionLeft.length > 0) {
97
+ leftStatus = fitStatusTexts(
98
+ extensionLeft,
99
+ Math.max(0, available - leftConnectorWidth),
100
+ separator,
101
+ );
102
+ remaining -= leftStatus ? leftConnectorWidth + visibleWidth(leftStatus) : 0;
103
+ } else if (extensionRight.length > 0) {
104
+ rightStatus = fitStatusTexts(
105
+ extensionRight,
106
+ Math.max(0, available - rightConnectorWidth),
107
+ separator,
108
+ );
109
+ remaining -= rightStatus ? rightConnectorWidth + visibleWidth(rightStatus) : 0;
110
+ }
111
+
112
+ const left = appendStatusArea(builtInLeft, leftStatus, separator);
113
+ const right = prependStatusArea(builtInRight, rightStatus, separator);
114
+ const gapWidth = Math.max(0, innerWidth - visibleWidth(left) - visibleWidth(right));
115
+ const middle = fitStatusTexts(extensionMiddle, gapWidth, separator);
116
+ const middleWidth = visibleWidth(middle);
117
+
118
+ if (!middle || middleWidth <= 0) {
119
+ return `${left}${" ".repeat(gapWidth)}${right}`;
120
+ }
121
+
122
+ const leftPadding = Math.floor((gapWidth - middleWidth) / 2);
123
+ const rightPadding = gapWidth - middleWidth - leftPadding;
124
+ return `${left}${" ".repeat(leftPadding)}${middle}${" ".repeat(rightPadding)}${right}`;
125
+ }
126
+
8
127
  export function installFooter(
9
128
  ctx: ExtensionContext,
10
129
  state: FooterState,
@@ -12,10 +131,12 @@ export function installFooter(
12
131
  hooks: {
13
132
  setRequestRender: (fn: (() => void) | undefined) => void;
14
133
  scheduleProjectRefresh: (ctx: ExtensionContext) => void;
134
+ setExtensionStatusesGetter?: (fn: (() => ReadonlyMap<string, string>) | undefined) => void;
15
135
  },
16
136
  ): void {
17
137
  ctx.ui.setFooter((tui, theme, footerData) => {
18
138
  hooks.setRequestRender(() => tui.requestRender());
139
+ hooks.setExtensionStatusesGetter?.(() => footerData.getExtensionStatuses());
19
140
  const unsubscribeBranch = footerData.onBranchChange(() => {
20
141
  hooks.scheduleProjectRefresh(ctx);
21
142
  tui.requestRender();
@@ -25,6 +146,7 @@ export function installFooter(
25
146
  dispose: () => {
26
147
  unsubscribeBranch();
27
148
  hooks.setRequestRender(undefined);
149
+ hooks.setExtensionStatusesGetter?.(undefined);
28
150
  },
29
151
  invalidate() {},
30
152
  render(width: number): string[] {
@@ -92,15 +214,21 @@ export function installFooter(
92
214
  renderStyleForSource(theme, colorSource, config.colors.tokens, state.tokenLabel),
93
215
  renderStyleForSource(theme, colorSource, config.colors.cost, state.costLabel),
94
216
  ].join(separator);
95
-
96
- const leftWidth = visibleWidth(left);
97
- const rightWidth = visibleWidth(right);
98
- const content =
99
- leftWidth >= innerWidth
100
- ? truncateToWidth(left, innerWidth, "")
101
- : leftWidth + 1 + rightWidth <= innerWidth
102
- ? `${left}${" ".repeat(innerWidth - leftWidth - rightWidth)}${right}`
103
- : truncateToWidth(left, innerWidth, "");
217
+ const extensionStatuses = collectExtensionStatusSegments(
218
+ footerData.getExtensionStatuses(),
219
+ config,
220
+ );
221
+ const renderExtensionStatus = (text: string) =>
222
+ renderStyleForSource(theme, colorSource, config.colors.extensionStatus, text);
223
+ const content = composeFooterContent(
224
+ left,
225
+ right,
226
+ extensionStatuses.left.map((segment) => renderExtensionStatus(segment.text)),
227
+ extensionStatuses.middle.map((segment) => renderExtensionStatus(segment.text)),
228
+ extensionStatuses.right.map((segment) => renderExtensionStatus(segment.text)),
229
+ separator,
230
+ innerWidth,
231
+ );
104
232
  const framed = width > 2 ? ` ${truncateToWidth(content, width - 2, "")} ` : content;
105
233
  return [truncateToWidth(framed, width, "")];
106
234
  },
@@ -11,9 +11,11 @@ export type UsageTotals = {
11
11
  };
12
12
 
13
13
  export function formatCount(value: number): string {
14
- if (value < 1000) return `${value}`;
14
+ if (value < 1000) return value.toString();
15
15
  if (value < 10_000) return `${(value / 1000).toFixed(1)}k`;
16
- return `${Math.round(value / 1000)}k`;
16
+ if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
17
+ if (value < 10_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
18
+ return `${Math.round(value / 1_000_000)}M`;
17
19
  }
18
20
 
19
21
  export function formatProviderLabel(provider: string | undefined): string {
@@ -38,19 +40,23 @@ export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
38
40
  let output = 0;
39
41
  let cost = 0;
40
42
 
41
- for (const entry of ctx.sessionManager.getBranch()) {
43
+ const entries = ctx.sessionManager.getEntries?.() ?? ctx.sessionManager.getBranch();
44
+ for (const entry of entries) {
42
45
  if (entry.type !== "message" || entry.message.role !== "assistant") continue;
43
- const message = entry.message as AssistantMessage;
44
- input += message.usage?.input ?? 0;
45
- output += message.usage?.output ?? 0;
46
- cost += message.usage?.cost?.total ?? 0;
46
+ const usage = (entry.message as AssistantMessage).usage;
47
+ input += usage?.input ?? 0;
48
+ output += usage?.output ?? 0;
49
+ cost += usage?.cost?.total ?? 0;
47
50
  }
48
51
 
49
52
  return { input, output, cost };
50
53
  }
51
54
 
52
55
  export function buildTokenLabel(totals: UsageTotals): string {
53
- return `↑${formatCount(totals.input)} ↓${formatCount(totals.output)}`;
56
+ const parts: string[] = [];
57
+ if (totals.input) parts.push(`↑${formatCount(totals.input)}`);
58
+ if (totals.output) parts.push(`↓${formatCount(totals.output)}`);
59
+ return parts.length > 0 ? parts.join(" ") : "↑0 ↓0";
54
60
  }
55
61
 
56
62
  export function buildCostLabel(totals: UsageTotals): string {
@@ -7,10 +7,12 @@ import type {
7
7
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
8
8
  import {
9
9
  type ColorSourcesConfig,
10
+ type ExtensionStatusPlacement,
10
11
  type PolishedTuiConfig,
11
12
  ensureConfigExists,
12
13
  loadConfig,
13
14
  saveColorSourcesPatch,
15
+ saveExtensionStatusPlacement,
14
16
  } from "./config";
15
17
  import { installFooter } from "./footer";
16
18
  import { emptyGitStatus, readGitStatus } from "./git";
@@ -28,6 +30,7 @@ export default function (pi: ExtensionAPI) {
28
30
  let currentConfig: PolishedTuiConfig = loadConfig();
29
31
  let activeTheme: Theme | undefined;
30
32
  let requestFooterRender: (() => void) | undefined;
33
+ let getActiveExtensionStatuses: () => ReadonlyMap<string, string> = () => new Map();
31
34
  let stopRefreshInterval: StopProjectRefreshInterval = () => {};
32
35
  let cleanupPrototypePatches: () => void = () => {};
33
36
  let projectRefreshInFlight = false;
@@ -109,6 +112,9 @@ export default function (pi: ExtensionAPI) {
109
112
  requestFooterRender = fn;
110
113
  },
111
114
  scheduleProjectRefresh,
115
+ setExtensionStatusesGetter(fn) {
116
+ getActiveExtensionStatuses = fn ?? (() => new Map());
117
+ },
112
118
  });
113
119
  installEditor(ctx);
114
120
  stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
@@ -126,6 +132,7 @@ export default function (pi: ExtensionAPI) {
126
132
  projectRefreshInFlight = false;
127
133
  projectRefreshPending = false;
128
134
  requestFooterRender = undefined;
135
+ getActiveExtensionStatuses = () => new Map();
129
136
  if (ctx?.hasUI) {
130
137
  ctx.ui.setFooter(undefined);
131
138
  ctx.ui.setEditorComponent(undefined);
@@ -133,6 +140,13 @@ export default function (pi: ExtensionAPI) {
133
140
  activeTheme = undefined;
134
141
  };
135
142
 
143
+ const syncInteractiveState = (_event: unknown, ctx: ExtensionContext) => {
144
+ refreshInteractiveState(ctx);
145
+ };
146
+ const syncInteractiveAndProjectState = (_event: unknown, ctx: ExtensionContext) => {
147
+ refreshInteractiveState(ctx, true);
148
+ };
149
+
136
150
  pi.on("session_start", async (_event, ctx) => {
137
151
  installUi(ctx);
138
152
  });
@@ -142,6 +156,12 @@ export default function (pi: ExtensionAPI) {
142
156
  setColorSources(patch: Partial<ColorSourcesConfig>) {
143
157
  currentConfig = saveColorSourcesPatch(patch);
144
158
  },
159
+ getActiveExtensionStatuses() {
160
+ return getActiveExtensionStatuses();
161
+ },
162
+ setExtensionStatusPlacement(key: string, placement: ExtensionStatusPlacement) {
163
+ currentConfig = saveExtensionStatusPlacement(key, placement);
164
+ },
145
165
  requestRender() {
146
166
  refresh();
147
167
  },
@@ -151,31 +171,12 @@ export default function (pi: ExtensionAPI) {
151
171
  cleanupUi(ctx);
152
172
  });
153
173
 
154
- pi.on("agent_start", async (_event, ctx) => {
155
- refreshInteractiveState(ctx);
156
- });
157
-
158
- pi.on("agent_end", async (_event, ctx) => {
159
- refreshInteractiveState(ctx, true);
160
- });
161
-
162
- pi.on("model_select", async (_event, ctx) => {
163
- refreshInteractiveState(ctx);
164
- });
165
-
166
- pi.on("thinking_level_select", async (_event, ctx) => {
167
- refreshInteractiveState(ctx);
168
- });
169
-
170
- pi.on("message_end", async (_event, ctx) => {
171
- refreshInteractiveState(ctx, true);
172
- });
173
-
174
- pi.on("tool_execution_end", async (_event, ctx) => {
175
- refreshInteractiveState(ctx, true);
176
- });
177
-
178
- pi.on("session_compact", async (_event, ctx) => {
179
- refreshInteractiveState(ctx, true);
180
- });
174
+ pi.on("agent_start", syncInteractiveState);
175
+ pi.on("agent_end", syncInteractiveAndProjectState);
176
+ pi.on("model_select", syncInteractiveState);
177
+ pi.on("thinking_level_select", syncInteractiveState);
178
+ pi.on("message_end", syncInteractiveAndProjectState);
179
+ pi.on("tool_execution_end", syncInteractiveAndProjectState);
180
+ pi.on("session_compact", syncInteractiveAndProjectState);
181
+ pi.on("session_tree", syncInteractiveAndProjectState);
181
182
  }
@@ -6,16 +6,32 @@ import {
6
6
  type SettingsListTheme,
7
7
  truncateToWidth,
8
8
  } from "@earendil-works/pi-tui";
9
- import type { ColorSource, ColorSourcesConfig, PolishedTuiConfig } from "./config";
9
+ import {
10
+ type ColorSource,
11
+ type ColorSourcesConfig,
12
+ type ExtensionStatusPlacement,
13
+ type PolishedTuiConfig,
14
+ getExtensionStatusPlacement,
15
+ isExtensionStatusPlacement,
16
+ } from "./config";
17
+ import { sanitizeExtensionStatusText } from "./extension-status";
10
18
  import { EDITOR_BORDER_STYLE, renderChromeBorder, safeThemeFg } from "./style";
11
19
 
12
20
  const colorSourceValues: ColorSource[] = ["theme", "terminal"];
21
+ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
22
+ "off",
23
+ "left",
24
+ "middle",
25
+ "right",
26
+ ];
13
27
 
14
28
  type SettingId = "starship" | "editorMessages";
15
29
 
16
30
  type SettingsCommandDeps = {
17
31
  getConfig: () => PolishedTuiConfig;
18
32
  setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
33
+ getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
34
+ setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
19
35
  requestRender: () => void;
20
36
  settingsListTheme?: SettingsListTheme;
21
37
  };
@@ -50,27 +66,114 @@ function patchForSetting(id: SettingId, value: ColorSource): Partial<ColorSource
50
66
  return id === "starship" ? { starship: value } : { editor: value, userMessages: value };
51
67
  }
52
68
 
53
- function buildItems(config: PolishedTuiConfig): SettingItem[] {
54
- return (Object.keys(settingLabels) as SettingId[]).map((key) => ({
55
- id: key,
56
- label: settingLabels[key],
57
- description: settingDescriptions[key],
58
- currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
59
- values: colorSourceValues,
60
- }));
69
+ function buildItems(
70
+ config: PolishedTuiConfig,
71
+ activeStatusCount: number,
72
+ thirdPartyStatusesSubmenu: SettingItem["submenu"],
73
+ ): SettingItem[] {
74
+ return [
75
+ ...(Object.keys(settingLabels) as SettingId[]).map((key) => ({
76
+ id: key,
77
+ label: settingLabels[key],
78
+ description: settingDescriptions[key],
79
+ currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
80
+ values: colorSourceValues,
81
+ })),
82
+ {
83
+ id: "thirdPartyStatuses",
84
+ label: "Third-party statuses",
85
+ description:
86
+ "Configure active ctx.ui.setStatus() footer statuses. Only currently active keys are listed.",
87
+ currentValue: `${activeStatusCount} active`,
88
+ submenu: thirdPartyStatusesSubmenu,
89
+ },
90
+ ];
61
91
  }
62
92
 
63
93
  export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
64
94
  pi.registerCommand("zentui", {
65
- description: "Configure Zentui colors",
95
+ description: "Configure Zentui",
66
96
  handler: async (_args, ctx) => {
67
97
  if (!ctx.hasUI) return;
68
98
 
69
99
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
100
+ const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
101
+ const makeThirdPartyStatusesSubmenu: SettingItem["submenu"] = (_currentValue, close) => {
102
+ const activeStatuses = Array.from(deps.getActiveExtensionStatuses().entries()).sort(
103
+ ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
104
+ );
105
+
106
+ if (activeStatuses.length === 0) {
107
+ return {
108
+ render(width: number) {
109
+ return [
110
+ truncateToWidth(safeThemeFg(theme, "accent", "Third-party statuses"), width, ""),
111
+ "",
112
+ truncateToWidth(
113
+ safeThemeFg(theme, "muted", "No third-party statuses are active."),
114
+ width,
115
+ "",
116
+ ),
117
+ truncateToWidth(
118
+ safeThemeFg(
119
+ theme,
120
+ "muted",
121
+ "This menu only lists statuses currently published through ctx.ui.setStatus().",
122
+ ),
123
+ width,
124
+ "",
125
+ ),
126
+ "",
127
+ truncateToWidth(safeThemeFg(theme, "muted", "Esc to go back"), width, ""),
128
+ ];
129
+ },
130
+ invalidate() {},
131
+ handleInput(data: string) {
132
+ if (data === "\x1b" || data === "\u0003") close(undefined);
133
+ },
134
+ };
135
+ }
136
+
137
+ const statusItems: SettingItem[] = activeStatuses.map(([key, value]) => {
138
+ const sanitizedText = sanitizeExtensionStatusText(value);
139
+ return {
140
+ id: key,
141
+ label: key,
142
+ description: sanitizedText ? `Current status: ${sanitizedText}` : undefined,
143
+ currentValue: getExtensionStatusPlacement(deps.getConfig(), key),
144
+ values: extensionStatusPlacementValues,
145
+ };
146
+ });
147
+ const statusSettingsList = new SettingsList(
148
+ statusItems,
149
+ 8,
150
+ settingsListTheme,
151
+ (key, newValue) => {
152
+ if (!isExtensionStatusPlacement(newValue)) return;
153
+
154
+ try {
155
+ deps.setExtensionStatusPlacement(key, newValue);
156
+ statusSettingsList.updateValue(key, newValue);
157
+ deps.requestRender();
158
+ ctx.ui.notify(`Third-party status ${key}: ${newValue}`, "info");
159
+ tui.requestRender();
160
+ } catch (error) {
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
163
+ }
164
+ },
165
+ () => close(undefined),
166
+ );
167
+ return statusSettingsList;
168
+ };
70
169
  const settingsList = new SettingsList(
71
- buildItems(deps.getConfig()),
170
+ buildItems(
171
+ deps.getConfig(),
172
+ deps.getActiveExtensionStatuses().size,
173
+ makeThirdPartyStatusesSubmenu,
174
+ ),
72
175
  5,
73
- deps.settingsListTheme ?? getSettingsListTheme(),
176
+ settingsListTheme,
74
177
  (id, newValue) => {
75
178
  if (!isSettingId(id) || !isColorSource(newValue)) return;
76
179
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -45,6 +45,6 @@
45
45
  "@earendil-works/pi-tui": "^0.74.0",
46
46
  "@types/node": "^25.5.2",
47
47
  "typescript": "^6.0.2",
48
- "vitest": "^3.2.4"
48
+ "vitest": "^4.1.8"
49
49
  }
50
50
  }