pi-zentui 0.1.13 → 0.1.14

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
@@ -123,7 +123,18 @@ pi install git:github.com/lmilojevicc/pi-zentui
123
123
 
124
124
  ## Config
125
125
 
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.
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, UI feature toggles, and active third-party status placements.
127
+
128
+ Useful slash-command shortcuts:
129
+
130
+ ```text
131
+ /zentui editor enable
132
+ /zentui editor disable
133
+ /zentui statusline enable
134
+ /zentui statusline disable
135
+ /zentui editor toggle
136
+ /zentui statusline toggle
137
+ ```
127
138
 
128
139
  Default config values — copy this and change any value you want:
129
140
 
@@ -143,7 +154,8 @@ Default config values — copy this and change any value you want:
143
154
  "staged": "+",
144
155
  "renamed": "»",
145
156
  "deleted": "✘",
146
- "typechanged": "T"
157
+ "typechanged": "T",
158
+ "cacheHit": "󰆼"
147
159
  },
148
160
  "colors": {
149
161
  "cwd": "bold cyan",
@@ -173,6 +185,10 @@ Default config values — copy this and change any value you want:
173
185
  "editor": "theme",
174
186
  "userMessages": "theme"
175
187
  },
188
+ "features": {
189
+ "editor": true,
190
+ "statusLine": true
191
+ },
176
192
  "extensionStatuses": {
177
193
  "defaultPlacement": "right",
178
194
  "placements": {}
@@ -184,6 +200,7 @@ Default config values — copy this and change any value you want:
184
200
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
185
201
  - `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
186
202
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
203
+ - `features`: `editor` enables Zentui's custom editor, selector borders, and previous-message chrome. `statusLine` enables Zentui's custom footer/status line. Both can be changed from `/zentui` or direct slash-command arguments.
187
204
  - `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.
188
205
  - 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`.
189
206
  - `editorAccent` styles the active editor rail and previous user-message rail.
@@ -12,6 +12,11 @@ export type ColorSourcesConfig = {
12
12
  userMessages: ColorSource;
13
13
  };
14
14
 
15
+ export type UiFeaturesConfig = {
16
+ editor: boolean;
17
+ statusLine: boolean;
18
+ };
19
+
15
20
  export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
16
21
 
17
22
  export type ExtensionStatusesConfig = {
@@ -38,6 +43,7 @@ export type PolishedTuiConfig = {
38
43
  renamed: string;
39
44
  deleted: string;
40
45
  typechanged: string;
46
+ cacheHit: string;
41
47
  };
42
48
  colors: {
43
49
  cwd: ColorSpec;
@@ -63,6 +69,7 @@ export type PolishedTuiConfig = {
63
69
  editorThinkingXhigh?: ColorSpec;
64
70
  };
65
71
  colorSources: ColorSourcesConfig;
72
+ features: UiFeaturesConfig;
66
73
  extensionStatuses: ExtensionStatusesConfig;
67
74
  };
68
75
 
@@ -84,6 +91,7 @@ export const defaultConfig: PolishedTuiConfig = {
84
91
  renamed: "»",
85
92
  deleted: "✘",
86
93
  typechanged: "T",
94
+ cacheHit: "󰆼",
87
95
  },
88
96
  colors: {
89
97
  cwd: "bold cyan",
@@ -103,6 +111,10 @@ export const defaultConfig: PolishedTuiConfig = {
103
111
  editor: "theme",
104
112
  userMessages: "theme",
105
113
  },
114
+ features: {
115
+ editor: true,
116
+ statusLine: true,
117
+ },
106
118
  extensionStatuses: {
107
119
  defaultPlacement: "right",
108
120
  placements: {},
@@ -123,6 +135,7 @@ const iconKeys = [
123
135
  "renamed",
124
136
  "deleted",
125
137
  "typechanged",
138
+ "cacheHit",
126
139
  ] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
127
140
 
128
141
  type ConfigRecord = Record<string, unknown>;
@@ -161,6 +174,11 @@ function colorSourceValue(
161
174
  return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
162
175
  }
163
176
 
177
+ function booleanValue(record: Record<string, unknown>, key: keyof UiFeaturesConfig): boolean {
178
+ const value = record[key];
179
+ return typeof value === "boolean" ? value : defaultConfig.features[key];
180
+ }
181
+
164
182
  function definedColors(
165
183
  colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
166
184
  ): Partial<PolishedTuiConfig["colors"]> {
@@ -214,6 +232,13 @@ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesCon
214
232
  };
215
233
  }
216
234
 
235
+ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig {
236
+ return {
237
+ editor: booleanValue(record, "editor"),
238
+ statusLine: booleanValue(record, "statusLine"),
239
+ };
240
+ }
241
+
217
242
  export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
218
243
  return value === "off" || value === "left" || value === "middle" || value === "right";
219
244
  }
@@ -241,6 +266,10 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
241
266
  return value === "starship" || value === "editor" || value === "userMessages";
242
267
  }
243
268
 
269
+ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
270
+ return value === "editor" || value === "statusLine";
271
+ }
272
+
244
273
  function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
245
274
  return Object.fromEntries(
246
275
  Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
@@ -250,6 +279,15 @@ function validColorSourceEntries(record: Record<string, unknown>): Partial<Color
250
279
  ) as Partial<ColorSourcesConfig>;
251
280
  }
252
281
 
282
+ function validUiFeatureEntries(record: Record<string, unknown>): Partial<UiFeaturesConfig> {
283
+ return Object.fromEntries(
284
+ Object.entries(record).filter((entry): entry is [keyof UiFeaturesConfig, boolean] => {
285
+ const [key, value] = entry;
286
+ return isUiFeatureKey(key) && typeof value === "boolean";
287
+ }),
288
+ ) as Partial<UiFeaturesConfig>;
289
+ }
290
+
253
291
  function readConfigRecord(path = configPath): ConfigRecord {
254
292
  try {
255
293
  if (!existsSync(path)) return {};
@@ -278,6 +316,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
278
316
  const colorSources = isRecord(config.colorSources)
279
317
  ? normalizeColorSources(config.colorSources as Record<string, unknown>)
280
318
  : defaultConfig.colorSources;
319
+ const features = isRecord(config.features)
320
+ ? normalizeUiFeatures(config.features as Record<string, unknown>)
321
+ : defaultConfig.features;
281
322
  const extensionStatuses = isRecord(config.extensionStatuses)
282
323
  ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>)
283
324
  : defaultConfig.extensionStatuses;
@@ -292,6 +333,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
292
333
  ...colors,
293
334
  },
294
335
  colorSources: { ...colorSources },
336
+ features: { ...features },
295
337
  extensionStatuses: {
296
338
  defaultPlacement: extensionStatuses.defaultPlacement,
297
339
  placements: { ...extensionStatuses.placements },
@@ -331,6 +373,22 @@ export function saveColorSourcesPatch(
331
373
  return mergeConfig(record);
332
374
  }
333
375
 
376
+ export function saveUiFeaturesPatch(
377
+ patch: Partial<UiFeaturesConfig>,
378
+ path = configPath,
379
+ ): PolishedTuiConfig {
380
+ const record = readConfigRecord(path);
381
+ const existing = isRecord(record.features)
382
+ ? { ...(record.features as Record<string, unknown>) }
383
+ : {};
384
+ record.features = {
385
+ ...existing,
386
+ ...validUiFeatureEntries(patch),
387
+ };
388
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
389
+ return mergeConfig(record);
390
+ }
391
+
334
392
  export function saveExtensionStatusPlacement(
335
393
  key: string,
336
394
  placement: ExtensionStatusPlacement,
@@ -7,6 +7,9 @@ import { renderStyleForSource } from "./style";
7
7
  export type UsageTotals = {
8
8
  input: number;
9
9
  output: number;
10
+ cacheRead: number;
11
+ cacheWrite: number;
12
+ latestCacheHitRate?: number;
10
13
  cost: number;
11
14
  };
12
15
 
@@ -35,27 +38,52 @@ export function formatProviderLabel(provider: string | undefined): string {
35
38
  );
36
39
  }
37
40
 
41
+ function calculateCacheHitRate(
42
+ input: number,
43
+ cacheRead: number,
44
+ cacheWrite: number,
45
+ ): number | undefined {
46
+ const promptTokens = input + cacheRead + cacheWrite;
47
+ return promptTokens > 0 ? (cacheRead / promptTokens) * 100 : undefined;
48
+ }
49
+
38
50
  export function getUsageTotals(ctx: ExtensionContext): UsageTotals {
39
51
  let input = 0;
40
52
  let output = 0;
53
+ let cacheRead = 0;
54
+ let cacheWrite = 0;
55
+ let latestCacheHitRate: number | undefined;
41
56
  let cost = 0;
42
57
 
43
58
  const entries = ctx.sessionManager.getEntries?.() ?? ctx.sessionManager.getBranch();
44
59
  for (const entry of entries) {
45
60
  if (entry.type !== "message" || entry.message.role !== "assistant") continue;
46
61
  const usage = (entry.message as AssistantMessage).usage;
47
- input += usage?.input ?? 0;
62
+ const entryInput = usage?.input ?? 0;
63
+ const entryCacheRead = usage?.cacheRead ?? 0;
64
+ const entryCacheWrite = usage?.cacheWrite ?? 0;
65
+
66
+ input += entryInput;
48
67
  output += usage?.output ?? 0;
68
+ cacheRead += entryCacheRead;
69
+ cacheWrite += entryCacheWrite;
49
70
  cost += usage?.cost?.total ?? 0;
71
+ latestCacheHitRate = calculateCacheHitRate(entryInput, entryCacheRead, entryCacheWrite);
50
72
  }
51
73
 
52
- return { input, output, cost };
74
+ return { input, output, cacheRead, cacheWrite, latestCacheHitRate, cost };
53
75
  }
54
76
 
55
- export function buildTokenLabel(totals: UsageTotals): string {
77
+ export function buildTokenLabel(totals: UsageTotals, cacheHitIcon = "󰆼"): string {
56
78
  const parts: string[] = [];
57
79
  if (totals.input) parts.push(`↑${formatCount(totals.input)}`);
58
80
  if (totals.output) parts.push(`↓${formatCount(totals.output)}`);
81
+
82
+ const hasCacheTokens = totals.cacheRead > 0 || totals.cacheWrite > 0;
83
+ if (hasCacheTokens && totals.latestCacheHitRate !== undefined) {
84
+ const cacheHitRate = `${totals.latestCacheHitRate.toFixed(1)}%`;
85
+ parts.push(cacheHitIcon ? `${cacheHitIcon} ${cacheHitRate}` : cacheHitRate);
86
+ }
59
87
  return parts.length > 0 ? parts.join(" ") : "↑0 ↓0";
60
88
  }
61
89
 
@@ -9,10 +9,12 @@ import {
9
9
  type ColorSourcesConfig,
10
10
  type ExtensionStatusPlacement,
11
11
  type PolishedTuiConfig,
12
+ type UiFeaturesConfig,
12
13
  ensureConfigExists,
13
14
  loadConfig,
14
15
  saveColorSourcesPatch,
15
16
  saveExtensionStatusPlacement,
17
+ saveUiFeaturesPatch,
16
18
  } from "./config";
17
19
  import { installFooter } from "./footer";
18
20
  import { emptyGitStatus, readGitStatus } from "./git";
@@ -24,6 +26,22 @@ import { type FooterState, createInitialState, syncState } from "./state";
24
26
  import { PolishedEditor } from "./ui";
25
27
  import { installUserMessageStyle } from "./user-message";
26
28
 
29
+ const ZENTUI_EDITOR_FACTORY = Symbol.for("pi-zentui.editor-factory");
30
+
31
+ type EditorFactory = NonNullable<Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0]>;
32
+
33
+ type ZentuiEditorFactory = EditorFactory & {
34
+ [ZENTUI_EDITOR_FACTORY]?: true;
35
+ };
36
+
37
+ type ApplyUiResult = {
38
+ editorBlocked: boolean;
39
+ };
40
+
41
+ function isZentuiEditorFactory(factory: EditorFactory | undefined): boolean {
42
+ return Boolean((factory as ZentuiEditorFactory | undefined)?.[ZENTUI_EDITOR_FACTORY]);
43
+ }
44
+
27
45
  export default function (pi: ExtensionAPI) {
28
46
  const state: FooterState = createInitialState(emptyGitStatus());
29
47
 
@@ -33,6 +51,9 @@ export default function (pi: ExtensionAPI) {
33
51
  let getActiveExtensionStatuses: () => ReadonlyMap<string, string> = () => new Map();
34
52
  let stopRefreshInterval: StopProjectRefreshInterval = () => {};
35
53
  let cleanupPrototypePatches: () => void = () => {};
54
+ let footerInstalled = false;
55
+ let editorInstalled = false;
56
+ let prototypePatchesInstalled = false;
36
57
  let projectRefreshInFlight = false;
37
58
  let projectRefreshPending = false;
38
59
 
@@ -40,6 +61,8 @@ export default function (pi: ExtensionAPI) {
40
61
  const getActiveTheme = () => activeTheme;
41
62
  const getCurrentConfig = () => currentConfig;
42
63
  const getThinkingLevel = () => pi.getThinkingLevel();
64
+ const syncFooterState = (ctx: ExtensionContext) =>
65
+ syncState(state, ctx, currentConfig.icons.cacheHit);
43
66
 
44
67
  const refreshProjectState = async (ctx: ExtensionContext) => {
45
68
  const [gitStatus, runtime] = await Promise.all([
@@ -69,44 +92,75 @@ export default function (pi: ExtensionAPI) {
69
92
 
70
93
  const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
71
94
  if (!ctx.hasUI) return;
72
- syncState(state, ctx);
73
- if (project) scheduleProjectRefresh(ctx);
95
+ syncFooterState(ctx);
96
+ if (project && currentConfig.features.statusLine) scheduleProjectRefresh(ctx);
74
97
  refresh();
75
98
  };
76
99
 
77
- const installEditor = (ctx: ExtensionContext) => {
78
- ctx.ui.setEditorComponent(
79
- (tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
80
- new PolishedEditor(
81
- tui,
82
- theme,
83
- keybindings,
84
- ctx.ui.theme,
85
- getCurrentConfig,
86
- () => ({
87
- modelLabel: state.modelLabel,
88
- providerLabel: state.providerLabel,
89
- }),
90
- getThinkingLevel,
91
- ),
92
- );
100
+ const stopProjectRefresh = () => {
101
+ stopRefreshInterval();
102
+ stopRefreshInterval = () => {};
103
+ projectRefreshInFlight = false;
104
+ projectRefreshPending = false;
93
105
  };
94
106
 
95
- const installUi = (ctx: ExtensionContext) => {
96
- if (!ctx.hasUI) return;
97
- activeTheme = ctx.ui.theme;
98
- cleanupPrototypePatches();
107
+ const installPrototypePatches = () => {
108
+ if (prototypePatchesInstalled) return;
99
109
  const cleanupSelectorBorderStyle = installSelectorBorderStyle(getActiveTheme, getCurrentConfig);
100
110
  const cleanupUserMessageStyle = installUserMessageStyle(getActiveTheme, getCurrentConfig);
101
111
  cleanupPrototypePatches = () => {
102
112
  cleanupSelectorBorderStyle();
103
113
  cleanupUserMessageStyle();
104
114
  };
105
- ensureConfigExists();
106
- currentConfig = loadConfig();
107
- syncState(state, ctx);
108
- stopRefreshInterval();
109
- stopRefreshInterval = () => {};
115
+ prototypePatchesInstalled = true;
116
+ };
117
+
118
+ const uninstallPrototypePatches = () => {
119
+ cleanupPrototypePatches();
120
+ cleanupPrototypePatches = () => {};
121
+ prototypePatchesInstalled = false;
122
+ };
123
+
124
+ const makeEditorFactory = (ctx: ExtensionContext): ZentuiEditorFactory => {
125
+ const factory = ((tui: TUI, theme: EditorTheme, keybindings: KeybindingsManager) =>
126
+ new PolishedEditor(
127
+ tui,
128
+ theme,
129
+ keybindings,
130
+ ctx.ui.theme,
131
+ getCurrentConfig,
132
+ () => ({
133
+ modelLabel: state.modelLabel,
134
+ providerLabel: state.providerLabel,
135
+ }),
136
+ getThinkingLevel,
137
+ )) as ZentuiEditorFactory;
138
+ factory[ZENTUI_EDITOR_FACTORY] = true;
139
+ return factory;
140
+ };
141
+
142
+ const installEditor = (ctx: ExtensionContext): boolean => {
143
+ const currentFactory = ctx.ui.getEditorComponent();
144
+ if (currentFactory && !isZentuiEditorFactory(currentFactory)) return false;
145
+
146
+ installPrototypePatches();
147
+ ctx.ui.setEditorComponent(makeEditorFactory(ctx));
148
+ editorInstalled = true;
149
+ return true;
150
+ };
151
+
152
+ const uninstallEditor = (ctx: ExtensionContext): boolean => {
153
+ const currentFactory = ctx.ui.getEditorComponent();
154
+ if (currentFactory && !isZentuiEditorFactory(currentFactory)) return false;
155
+
156
+ uninstallPrototypePatches();
157
+ ctx.ui.setEditorComponent(undefined);
158
+ editorInstalled = false;
159
+ return true;
160
+ };
161
+
162
+ const installStatusLine = (ctx: ExtensionContext) => {
163
+ if (footerInstalled) return;
110
164
  installFooter(ctx, state, getCurrentConfig, {
111
165
  setRequestRender: (fn) => {
112
166
  requestFooterRender = fn;
@@ -116,7 +170,8 @@ export default function (pi: ExtensionAPI) {
116
170
  getActiveExtensionStatuses = fn ?? (() => new Map());
117
171
  },
118
172
  });
119
- installEditor(ctx);
173
+ footerInstalled = true;
174
+ stopProjectRefresh();
120
175
  stopRefreshInterval = startProjectRefreshInterval(currentConfig.projectRefreshIntervalMs, () =>
121
176
  scheduleProjectRefresh(ctx),
122
177
  );
@@ -124,19 +179,57 @@ export default function (pi: ExtensionAPI) {
124
179
  refresh();
125
180
  };
126
181
 
182
+ const uninstallStatusLine = (ctx: ExtensionContext) => {
183
+ stopProjectRefresh();
184
+ ctx.ui.setFooter(undefined);
185
+ footerInstalled = false;
186
+ requestFooterRender = undefined;
187
+ getActiveExtensionStatuses = () => new Map();
188
+ };
189
+
190
+ const applyConfiguredUi = (ctx: ExtensionContext): ApplyUiResult => {
191
+ const result: ApplyUiResult = { editorBlocked: false };
192
+ if (!ctx.hasUI) return result;
193
+ activeTheme = ctx.ui.theme;
194
+ if (currentConfig.features.editor) {
195
+ if (!editorInstalled) result.editorBlocked = !installEditor(ctx);
196
+ } else if (editorInstalled || prototypePatchesInstalled) {
197
+ result.editorBlocked = !uninstallEditor(ctx);
198
+ }
199
+
200
+ if (currentConfig.features.statusLine) {
201
+ installStatusLine(ctx);
202
+ } else if (footerInstalled) {
203
+ uninstallStatusLine(ctx);
204
+ }
205
+ return result;
206
+ };
207
+
208
+ const installUi = (ctx: ExtensionContext) => {
209
+ if (!ctx.hasUI) return;
210
+ activeTheme = ctx.ui.theme;
211
+ uninstallPrototypePatches();
212
+ footerInstalled = false;
213
+ editorInstalled = false;
214
+ ensureConfigExists();
215
+ currentConfig = loadConfig();
216
+ syncFooterState(ctx);
217
+ stopProjectRefresh();
218
+ applyConfiguredUi(ctx);
219
+ refresh();
220
+ };
221
+
127
222
  const cleanupUi = (ctx?: ExtensionContext) => {
128
- cleanupPrototypePatches();
129
- cleanupPrototypePatches = () => {};
130
- stopRefreshInterval();
131
- stopRefreshInterval = () => {};
132
- projectRefreshInFlight = false;
133
- projectRefreshPending = false;
223
+ uninstallPrototypePatches();
224
+ stopProjectRefresh();
134
225
  requestFooterRender = undefined;
135
226
  getActiveExtensionStatuses = () => new Map();
136
227
  if (ctx?.hasUI) {
137
228
  ctx.ui.setFooter(undefined);
138
229
  ctx.ui.setEditorComponent(undefined);
139
230
  }
231
+ footerInstalled = false;
232
+ editorInstalled = false;
140
233
  activeTheme = undefined;
141
234
  };
142
235
 
@@ -156,6 +249,16 @@ export default function (pi: ExtensionAPI) {
156
249
  setColorSources(patch: Partial<ColorSourcesConfig>) {
157
250
  currentConfig = saveColorSourcesPatch(patch);
158
251
  },
252
+ setUiFeatures(patch: Partial<UiFeaturesConfig>, ctx: ExtensionContext) {
253
+ currentConfig = saveUiFeaturesPatch(patch);
254
+ const result = applyConfiguredUi(ctx);
255
+ return {
256
+ applied: !(patch.editor !== undefined && result.editorBlocked),
257
+ reason: result.editorBlocked
258
+ ? "another extension is currently managing the editor; reload Pi to apply this change"
259
+ : undefined,
260
+ };
261
+ },
159
262
  getActiveExtensionStatuses() {
160
263
  return getActiveExtensionStatuses();
161
264
  },
@@ -1,6 +1,7 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
3
  import {
4
+ type AutocompleteItem,
4
5
  type SettingItem,
5
6
  SettingsList,
6
7
  type SettingsListTheme,
@@ -11,6 +12,7 @@ import {
11
12
  type ColorSourcesConfig,
12
13
  type ExtensionStatusPlacement,
13
14
  type PolishedTuiConfig,
15
+ type UiFeaturesConfig,
14
16
  getExtensionStatusPlacement,
15
17
  isExtensionStatusPlacement,
16
18
  } from "./config";
@@ -24,58 +26,152 @@ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
24
26
  "middle",
25
27
  "right",
26
28
  ];
29
+ type FeatureState = "enabled" | "disabled";
27
30
 
28
- type SettingId = "starship" | "editorMessages";
31
+ const featureStateValues: FeatureState[] = ["enabled", "disabled"];
32
+
33
+ type ColorSettingId = "starship" | "editorMessages";
34
+ type FeatureSettingId = keyof UiFeaturesConfig;
29
35
 
30
36
  type SettingsCommandDeps = {
31
37
  getConfig: () => PolishedTuiConfig;
32
38
  setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
39
+ setUiFeatures: (
40
+ patch: Partial<UiFeaturesConfig>,
41
+ ctx: ExtensionContext,
42
+ ) => { applied: boolean; reason?: string };
33
43
  getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
34
44
  setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
35
45
  requestRender: () => void;
36
46
  settingsListTheme?: SettingsListTheme;
37
47
  };
38
48
 
39
- const settingLabels: Record<SettingId, string> = {
49
+ const colorSettingLabels: Record<ColorSettingId, string> = {
40
50
  starship: "Starship/footer colors",
41
51
  editorMessages: "Editor + previous messages",
42
52
  };
43
53
 
44
- const settingDescriptions: Record<SettingId, string> = {
54
+ const colorSettingDescriptions: Record<ColorSettingId, string> = {
45
55
  starship:
46
56
  "Choose whether footer runtime/git/context colors use Pi theme tokens or terminal palette styles.",
47
57
  editorMessages:
48
58
  "Choose whether editor and previous user-message borders/rails use Pi theme colors or terminal palette styles.",
49
59
  };
50
60
 
61
+ const featureSettingLabels: Record<FeatureSettingId, string> = {
62
+ editor: "Editor",
63
+ statusLine: "Status line",
64
+ };
65
+
66
+ const featureSettingDescriptions: Record<FeatureSettingId, string> = {
67
+ editor:
68
+ "Enable or disable Zentui's custom editor, selector borders, and previous-message chrome.",
69
+ statusLine: "Enable or disable Zentui's custom footer/status line.",
70
+ };
71
+
72
+ const directCommandSuggestions = [
73
+ "editor enable",
74
+ "editor disable",
75
+ "editor toggle",
76
+ "statusline enable",
77
+ "statusline disable",
78
+ "statusline toggle",
79
+ ];
80
+
51
81
  function isColorSource(value: string): value is ColorSource {
52
82
  return value === "theme" || value === "terminal";
53
83
  }
54
84
 
55
- function isSettingId(value: string): value is SettingId {
85
+ function isColorSettingId(value: string): value is ColorSettingId {
56
86
  return value === "starship" || value === "editorMessages";
57
87
  }
58
88
 
89
+ function isFeatureSettingId(value: string): value is FeatureSettingId {
90
+ return value === "editor" || value === "statusLine";
91
+ }
92
+
93
+ function isFeatureState(value: string): value is FeatureState {
94
+ return value === "enabled" || value === "disabled";
95
+ }
96
+
59
97
  function editorMessageValue(config: PolishedTuiConfig): ColorSource | "mixed" {
60
98
  return config.colorSources.editor === config.colorSources.userMessages
61
99
  ? config.colorSources.editor
62
100
  : "mixed";
63
101
  }
64
102
 
65
- function patchForSetting(id: SettingId, value: ColorSource): Partial<ColorSourcesConfig> {
103
+ function patchForSetting(id: ColorSettingId, value: ColorSource): Partial<ColorSourcesConfig> {
66
104
  return id === "starship" ? { starship: value } : { editor: value, userMessages: value };
67
105
  }
68
106
 
107
+ function featureValue(enabled: boolean): FeatureState {
108
+ return enabled ? "enabled" : "disabled";
109
+ }
110
+
111
+ function featurePatch(id: FeatureSettingId, value: FeatureState): Partial<UiFeaturesConfig> {
112
+ return { [id]: value === "enabled" } as Partial<UiFeaturesConfig>;
113
+ }
114
+
115
+ function usageText(): string {
116
+ return "Usage: /zentui [editor|statusline] [enable|disable|toggle]";
117
+ }
118
+
119
+ function featureNotification(
120
+ feature: FeatureSettingId,
121
+ value: FeatureState,
122
+ result: { applied: boolean; reason?: string },
123
+ ): string {
124
+ const base = `${featureSettingLabels[feature]}: ${value}`;
125
+ return result.applied ? base : `${base} (${result.reason ?? "reload Pi to apply this change"})`;
126
+ }
127
+
128
+ function parseDirectFeatureCommand(
129
+ args: string,
130
+ config: PolishedTuiConfig,
131
+ ): { feature: FeatureSettingId; enabled: boolean } | undefined {
132
+ const normalized = args.trim().toLowerCase().replaceAll(/[_-]+/g, " ");
133
+ if (!normalized) return undefined;
134
+
135
+ const words = normalized.split(/\s+/g).filter(Boolean);
136
+ const hasWord = (value: string) => words.includes(value);
137
+ const feature = hasWord("editor")
138
+ ? "editor"
139
+ : hasWord("footer") || hasWord("statusline") || hasWord("status")
140
+ ? "statusLine"
141
+ : undefined;
142
+ const action = hasWord("toggle")
143
+ ? "toggle"
144
+ : hasWord("enable") || hasWord("enabled") || hasWord("on")
145
+ ? "enable"
146
+ : hasWord("disable") || hasWord("disabled") || hasWord("off")
147
+ ? "disable"
148
+ : undefined;
149
+
150
+ if (!feature || !action) return undefined;
151
+
152
+ return {
153
+ feature,
154
+ enabled: action === "toggle" ? !config.features[feature] : action === "enable",
155
+ };
156
+ }
157
+
158
+ function argumentCompletions(prefix: string): AutocompleteItem[] | null {
159
+ const trimmedPrefix = prefix.trimStart().toLowerCase();
160
+ const items = directCommandSuggestions.map((value) => ({ value, label: value }));
161
+ const matches = items.filter((item) => item.value.startsWith(trimmedPrefix));
162
+ return matches.length > 0 ? matches : null;
163
+ }
164
+
69
165
  function buildItems(
70
166
  config: PolishedTuiConfig,
71
167
  activeStatusCount: number,
72
168
  thirdPartyStatusesSubmenu: SettingItem["submenu"],
73
169
  ): SettingItem[] {
74
170
  return [
75
- ...(Object.keys(settingLabels) as SettingId[]).map((key) => ({
171
+ ...(Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
76
172
  id: key,
77
- label: settingLabels[key],
78
- description: settingDescriptions[key],
173
+ label: colorSettingLabels[key],
174
+ description: colorSettingDescriptions[key],
79
175
  currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
80
176
  values: colorSourceValues,
81
177
  })),
@@ -87,17 +183,62 @@ function buildItems(
87
183
  currentValue: `${activeStatusCount} active`,
88
184
  submenu: thirdPartyStatusesSubmenu,
89
185
  },
186
+ ...(Object.keys(featureSettingLabels) as FeatureSettingId[]).map((key) => ({
187
+ id: key,
188
+ label: featureSettingLabels[key],
189
+ description: featureSettingDescriptions[key],
190
+ currentValue: featureValue(config.features[key]),
191
+ values: featureStateValues,
192
+ })),
90
193
  ];
91
194
  }
92
195
 
93
196
  export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
94
197
  pi.registerCommand("zentui", {
95
198
  description: "Configure Zentui",
199
+ getArgumentCompletions: argumentCompletions,
96
200
  handler: async (_args, ctx) => {
201
+ const args = typeof _args === "string" ? _args : "";
202
+ const directCommand = parseDirectFeatureCommand(args, deps.getConfig());
203
+ if (directCommand) {
204
+ try {
205
+ const result = deps.setUiFeatures(
206
+ { [directCommand.feature]: directCommand.enabled },
207
+ ctx,
208
+ );
209
+ deps.requestRender();
210
+ if (ctx.hasUI) {
211
+ ctx.ui.notify(
212
+ featureNotification(
213
+ directCommand.feature,
214
+ featureValue(directCommand.enabled),
215
+ result,
216
+ ),
217
+ "info",
218
+ );
219
+ }
220
+ } catch (error) {
221
+ const message = error instanceof Error ? error.message : String(error);
222
+ if (ctx.hasUI) ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
223
+ }
224
+ return;
225
+ }
226
+
227
+ if (args.trim()) {
228
+ if (ctx.hasUI) ctx.ui.notify(usageText(), "warning");
229
+ return;
230
+ }
231
+
97
232
  if (!ctx.hasUI) return;
98
233
 
99
234
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
100
235
  const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
236
+ const applyFeatureChange = (id: FeatureSettingId, newValue: FeatureState) => {
237
+ const result = deps.setUiFeatures(featurePatch(id, newValue), ctx);
238
+ deps.requestRender();
239
+ ctx.ui.notify(featureNotification(id, newValue, result), "info");
240
+ tui.requestRender();
241
+ };
101
242
  const makeThirdPartyStatusesSubmenu: SettingItem["submenu"] = (_currentValue, close) => {
102
243
  const activeStatuses = Array.from(deps.getActiveExtensionStatuses().entries()).sort(
103
244
  ([a], [b]) => (a < b ? -1 : a > b ? 1 : 0),
@@ -175,14 +316,36 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
175
316
  5,
176
317
  settingsListTheme,
177
318
  (id, newValue) => {
178
- if (!isSettingId(id) || !isColorSource(newValue)) return;
179
-
180
319
  try {
181
- deps.setColorSources(patchForSetting(id, newValue));
182
- settingsList.updateValue(id, newValue);
183
- deps.requestRender();
184
- ctx.ui.notify(`${settingLabels[id]}: ${newValue}`, "info");
185
- tui.requestRender();
320
+ if (isColorSettingId(id) && isColorSource(newValue)) {
321
+ deps.setColorSources(patchForSetting(id, newValue));
322
+ settingsList.updateValue(id, newValue);
323
+ deps.requestRender();
324
+ ctx.ui.notify(`${colorSettingLabels[id]}: ${newValue}`, "info");
325
+ tui.requestRender();
326
+ return;
327
+ }
328
+
329
+ if (isFeatureSettingId(id) && isFeatureState(newValue)) {
330
+ settingsList.updateValue(id, newValue);
331
+ if (id === "editor") {
332
+ done(undefined);
333
+ // Changing the editor component while ctx.ui.custom() is active clears the
334
+ // custom component without resolving it, leaving Pi's input loop stuck.
335
+ // Close the settings UI first, then apply the editor swap on the next tick.
336
+ setTimeout(() => {
337
+ try {
338
+ applyFeatureChange(id, newValue);
339
+ } catch (error) {
340
+ const message = error instanceof Error ? error.message : String(error);
341
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
342
+ }
343
+ }, 0);
344
+ return;
345
+ }
346
+
347
+ applyFeatureChange(id, newValue);
348
+ }
186
349
  } catch (error) {
187
350
  const message = error instanceof Error ? error.message : String(error);
188
351
  ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
@@ -1,6 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
- type UsageTotals,
4
3
  buildContextLabel,
5
4
  buildCostLabel,
6
5
  buildTokenLabel,
@@ -31,11 +30,11 @@ export function createInitialState(gitDefaults: GitStatusSummary): FooterState {
31
30
  };
32
31
  }
33
32
 
34
- export function syncState(state: FooterState, ctx: ExtensionContext): void {
33
+ export function syncState(state: FooterState, ctx: ExtensionContext, cacheHitIcon: string): void {
35
34
  const totals = getUsageTotals(ctx);
36
35
  state.modelLabel = ctx.model?.id ?? "no-model";
37
36
  state.providerLabel = formatProviderLabel(ctx.model?.provider);
38
37
  state.contextLabel = buildContextLabel(ctx);
39
- state.tokenLabel = buildTokenLabel(totals);
38
+ state.tokenLabel = buildTokenLabel(totals, cacheHitIcon);
40
39
  state.costLabel = buildCostLabel(totals);
41
40
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",