pi-zentui 0.1.13 → 0.1.15

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
@@ -11,7 +11,7 @@ A Starship-inspired statusline and Opencode-style TUI for [Pi](https://pi.dev).
11
11
  Zentui brings two popular aesthetics to Pi:
12
12
 
13
13
  - **[Starship](https://starship.rs/) footer** — shows your current directory, git branch, git status indicators, and runtime/version detection in a compact, icon-rich format
14
- - **[Opencode](https://github.com/opencode-ai/opencode) editor** — clean bordered input box with accent rail and model/provider display inside the editor frame
14
+ - **[Opencode](https://github.com/opencode-ai/opencode) editor** — clean bordered input box with accent rail, copy-friendly mode, and model/provider display inside the editor frame
15
15
 
16
16
  ## Features
17
17
 
@@ -31,6 +31,7 @@ Zentui brings two popular aesthetics to Pi:
31
31
  - Model name and provider displayed inside the editor frame
32
32
  - Configurable model, provider, and thinking-level indicator colors
33
33
  - Prompt-box-style user messages matching the ZentUI input chrome
34
+ - Copy-friendly mode hides editor and previous-message rail glyphs so terminal selection copies less chrome
34
35
 
35
36
  ### Git Status Icons
36
37
 
@@ -123,7 +124,23 @@ pi install git:github.com/lmilojevicc/pi-zentui
123
124
 
124
125
  ## Config
125
126
 
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.
127
+ 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.
128
+
129
+ The interactive `/zentui` menu is split into three sections. Use `Tab` to switch between `Coloring`, `Features`, and `Status line`.
130
+
131
+ Useful slash-command shortcuts:
132
+
133
+ ```text
134
+ /zentui editor enable
135
+ /zentui editor disable
136
+ /zentui statusline enable
137
+ /zentui statusline disable
138
+ /zentui editor toggle
139
+ /zentui statusline toggle
140
+ /zentui copy-friendly enable
141
+ /zentui copy-friendly disable
142
+ /zentui copy-friendly toggle
143
+ ```
127
144
 
128
145
  Default config values — copy this and change any value you want:
129
146
 
@@ -143,7 +160,9 @@ Default config values — copy this and change any value you want:
143
160
  "staged": "+",
144
161
  "renamed": "»",
145
162
  "deleted": "✘",
146
- "typechanged": "T"
163
+ "typechanged": "T",
164
+ "cacheHit": "󰆼",
165
+ "editorPrompt": ""
147
166
  },
148
167
  "colors": {
149
168
  "cwd": "bold cyan",
@@ -158,6 +177,7 @@ Default config values — copy this and change any value you want:
158
177
  "separator": "bright-black",
159
178
  "runtimePrefix": "",
160
179
  "editorAccent": "accent",
180
+ "editorPrompt": "accent",
161
181
  "editorBorder": "borderMuted",
162
182
  "editorModel": "accent",
163
183
  "editorProvider": "text",
@@ -173,6 +193,11 @@ Default config values — copy this and change any value you want:
173
193
  "editor": "theme",
174
194
  "userMessages": "theme"
175
195
  },
196
+ "features": {
197
+ "editor": true,
198
+ "statusLine": true,
199
+ "copyFriendly": false
200
+ },
176
201
  "extensionStatuses": {
177
202
  "defaultPlacement": "right",
178
203
  "placements": {}
@@ -182,14 +207,18 @@ Default config values — copy this and change any value you want:
182
207
 
183
208
  - Style values can be Starship/terminal strings (`bold purple`, `fg:202`, `#89b4fa`, `bg:blue fg:bright-green`) or Pi theme tokens (`accent`, `borderMuted`, `thinkingHigh`).
184
209
  - `projectRefreshIntervalMs`: project status polling interval; `0` disables polling.
185
- - `icons`: every shown icon key is configurable; omit any key to use the Zentui default.
210
+ - `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.
186
211
  - `colorSources`: `theme` maps styles through Pi theme tokens; `terminal` emits terminal colors. `/zentui` switches these sources; manual JSON controls specific style values.
212
+ - `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.
187
213
  - `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
214
  - 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
- - `editorAccent` styles the active editor rail and previous user-message rail.
215
+ - `editorAccent` styles the active editor rail and previous user-message rail when `features.copyFriendly` is disabled.
216
+ - `editorPrompt` styles the copy-friendly editor prompt glyph. Omit it to use `editorAccent`, then the default accent fallback.
190
217
  - `editorBorder` styles the active editor and previous user-message top/bottom border color only; the border glyph stays `─`.
191
218
  - `editorModel`, `editorProvider`, and `editorThinking*` style the editor metadata. `editorThinking` applies to every non-`off` thinking level unless a level-specific key is set.
192
219
 
220
+ Tip: when using copy-friendly mode, setting Pi's `editorPaddingX` to `1` in `~/.pi/agent/settings.json` keeps a small left gutter without copying a rail glyph.
221
+
193
222
  ## Requirements
194
223
 
195
224
  - [Pi](https://pi.dev) coding agent 0.74 or newer
@@ -12,6 +12,12 @@ export type ColorSourcesConfig = {
12
12
  userMessages: ColorSource;
13
13
  };
14
14
 
15
+ export type UiFeaturesConfig = {
16
+ editor: boolean;
17
+ statusLine: boolean;
18
+ copyFriendly: boolean;
19
+ };
20
+
15
21
  export type ExtensionStatusPlacement = "off" | "left" | "middle" | "right";
16
22
 
17
23
  export type ExtensionStatusesConfig = {
@@ -38,6 +44,8 @@ export type PolishedTuiConfig = {
38
44
  renamed: string;
39
45
  deleted: string;
40
46
  typechanged: string;
47
+ cacheHit: string;
48
+ editorPrompt: string;
41
49
  };
42
50
  colors: {
43
51
  cwd: ColorSpec;
@@ -52,6 +60,7 @@ export type PolishedTuiConfig = {
52
60
  runtimePrefix: ColorSpec;
53
61
  extensionStatus: ColorSpec;
54
62
  editorAccent?: ColorSpec;
63
+ editorPrompt?: ColorSpec;
55
64
  editorBorder?: ColorSpec;
56
65
  editorModel?: ColorSpec;
57
66
  editorProvider?: ColorSpec;
@@ -63,6 +72,7 @@ export type PolishedTuiConfig = {
63
72
  editorThinkingXhigh?: ColorSpec;
64
73
  };
65
74
  colorSources: ColorSourcesConfig;
75
+ features: UiFeaturesConfig;
66
76
  extensionStatuses: ExtensionStatusesConfig;
67
77
  };
68
78
 
@@ -84,6 +94,8 @@ export const defaultConfig: PolishedTuiConfig = {
84
94
  renamed: "»",
85
95
  deleted: "✘",
86
96
  typechanged: "T",
97
+ cacheHit: "󰆼",
98
+ editorPrompt: "",
87
99
  },
88
100
  colors: {
89
101
  cwd: "bold cyan",
@@ -103,6 +115,11 @@ export const defaultConfig: PolishedTuiConfig = {
103
115
  editor: "theme",
104
116
  userMessages: "theme",
105
117
  },
118
+ features: {
119
+ editor: true,
120
+ statusLine: true,
121
+ copyFriendly: false,
122
+ },
106
123
  extensionStatuses: {
107
124
  defaultPlacement: "right",
108
125
  placements: {},
@@ -123,6 +140,8 @@ const iconKeys = [
123
140
  "renamed",
124
141
  "deleted",
125
142
  "typechanged",
143
+ "cacheHit",
144
+ "editorPrompt",
126
145
  ] as const satisfies readonly (keyof PolishedTuiConfig["icons"])[];
127
146
 
128
147
  type ConfigRecord = Record<string, unknown>;
@@ -161,6 +180,11 @@ function colorSourceValue(
161
180
  return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
162
181
  }
163
182
 
183
+ function booleanValue(record: Record<string, unknown>, key: keyof UiFeaturesConfig): boolean {
184
+ const value = record[key];
185
+ return typeof value === "boolean" ? value : defaultConfig.features[key];
186
+ }
187
+
164
188
  function definedColors(
165
189
  colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
166
190
  ): Partial<PolishedTuiConfig["colors"]> {
@@ -194,6 +218,7 @@ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiCo
194
218
  runtimePrefix: colorValue(record, "runtimePrefix"),
195
219
  extensionStatus: colorValue(record, "extensionStatus"),
196
220
  editorAccent: colorValue(record, "editorAccent"),
221
+ editorPrompt: colorValue(record, "editorPrompt"),
197
222
  editorBorder: colorValue(record, "editorBorder"),
198
223
  editorModel: colorValue(record, "editorModel"),
199
224
  editorProvider: colorValue(record, "editorProvider"),
@@ -214,6 +239,14 @@ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesCon
214
239
  };
215
240
  }
216
241
 
242
+ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig {
243
+ return {
244
+ editor: booleanValue(record, "editor"),
245
+ statusLine: booleanValue(record, "statusLine"),
246
+ copyFriendly: booleanValue(record, "copyFriendly"),
247
+ };
248
+ }
249
+
217
250
  export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
218
251
  return value === "off" || value === "left" || value === "middle" || value === "right";
219
252
  }
@@ -241,6 +274,10 @@ function isColorSourceKey(value: string): value is keyof ColorSourcesConfig {
241
274
  return value === "starship" || value === "editor" || value === "userMessages";
242
275
  }
243
276
 
277
+ function isUiFeatureKey(value: string): value is keyof UiFeaturesConfig {
278
+ return value === "editor" || value === "statusLine" || value === "copyFriendly";
279
+ }
280
+
244
281
  function validColorSourceEntries(record: Record<string, unknown>): Partial<ColorSourcesConfig> {
245
282
  return Object.fromEntries(
246
283
  Object.entries(record).filter((entry): entry is [keyof ColorSourcesConfig, ColorSource] => {
@@ -250,6 +287,15 @@ function validColorSourceEntries(record: Record<string, unknown>): Partial<Color
250
287
  ) as Partial<ColorSourcesConfig>;
251
288
  }
252
289
 
290
+ function validUiFeatureEntries(record: Record<string, unknown>): Partial<UiFeaturesConfig> {
291
+ return Object.fromEntries(
292
+ Object.entries(record).filter((entry): entry is [keyof UiFeaturesConfig, boolean] => {
293
+ const [key, value] = entry;
294
+ return isUiFeatureKey(key) && typeof value === "boolean";
295
+ }),
296
+ ) as Partial<UiFeaturesConfig>;
297
+ }
298
+
253
299
  function readConfigRecord(path = configPath): ConfigRecord {
254
300
  try {
255
301
  if (!existsSync(path)) return {};
@@ -278,6 +324,9 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
278
324
  const colorSources = isRecord(config.colorSources)
279
325
  ? normalizeColorSources(config.colorSources as Record<string, unknown>)
280
326
  : defaultConfig.colorSources;
327
+ const features = isRecord(config.features)
328
+ ? normalizeUiFeatures(config.features as Record<string, unknown>)
329
+ : defaultConfig.features;
281
330
  const extensionStatuses = isRecord(config.extensionStatuses)
282
331
  ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>)
283
332
  : defaultConfig.extensionStatuses;
@@ -292,6 +341,7 @@ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
292
341
  ...colors,
293
342
  },
294
343
  colorSources: { ...colorSources },
344
+ features: { ...features },
295
345
  extensionStatuses: {
296
346
  defaultPlacement: extensionStatuses.defaultPlacement,
297
347
  placements: { ...extensionStatuses.placements },
@@ -331,6 +381,22 @@ export function saveColorSourcesPatch(
331
381
  return mergeConfig(record);
332
382
  }
333
383
 
384
+ export function saveUiFeaturesPatch(
385
+ patch: Partial<UiFeaturesConfig>,
386
+ path = configPath,
387
+ ): PolishedTuiConfig {
388
+ const record = readConfigRecord(path);
389
+ const existing = isRecord(record.features)
390
+ ? { ...(record.features as Record<string, unknown>) }
391
+ : {};
392
+ record.features = {
393
+ ...existing,
394
+ ...validUiFeatureEntries(patch),
395
+ };
396
+ writeFileSync(path, `${JSON.stringify(record, null, 2)}\n`, "utf8");
397
+ return mergeConfig(record);
398
+ }
399
+
334
400
  export function saveExtensionStatusPlacement(
335
401
  key: string,
336
402
  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,172 +26,356 @@ 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
+ const settingsSections = ["coloring", "features", "statusLine"] as const;
33
+
34
+ type ColorSettingId = "starship" | "editorMessages";
35
+ type FeatureSettingId = keyof UiFeaturesConfig;
36
+ type SettingsSection = (typeof settingsSections)[number];
29
37
 
30
38
  type SettingsCommandDeps = {
31
39
  getConfig: () => PolishedTuiConfig;
32
40
  setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
41
+ setUiFeatures: (
42
+ patch: Partial<UiFeaturesConfig>,
43
+ ctx: ExtensionContext,
44
+ ) => { applied: boolean; reason?: string };
33
45
  getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
34
46
  setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
35
47
  requestRender: () => void;
36
48
  settingsListTheme?: SettingsListTheme;
37
49
  };
38
50
 
39
- const settingLabels: Record<SettingId, string> = {
51
+ const colorSettingLabels: Record<ColorSettingId, string> = {
40
52
  starship: "Starship/footer colors",
41
53
  editorMessages: "Editor + previous messages",
42
54
  };
43
55
 
44
- const settingDescriptions: Record<SettingId, string> = {
56
+ const colorSettingDescriptions: Record<ColorSettingId, string> = {
45
57
  starship:
46
58
  "Choose whether footer runtime/git/context colors use Pi theme tokens or terminal palette styles.",
47
59
  editorMessages:
48
60
  "Choose whether editor and previous user-message borders/rails use Pi theme colors or terminal palette styles.",
49
61
  };
50
62
 
63
+ const featureSettingLabels: Record<FeatureSettingId, string> = {
64
+ editor: "Editor",
65
+ statusLine: "Status line",
66
+ copyFriendly: "Copy-friendly mode",
67
+ };
68
+
69
+ const featureSettingDescriptions: Record<FeatureSettingId, string> = {
70
+ editor:
71
+ "Enable or disable Zentui's custom editor, selector borders, and previous-message chrome.",
72
+ statusLine: "Enable or disable Zentui's custom footer/status line.",
73
+ copyFriendly:
74
+ "Hide editor and previous-message rail glyphs for cleaner native terminal selection.",
75
+ };
76
+
77
+ const directCommandSuggestions = [
78
+ "editor enable",
79
+ "editor disable",
80
+ "editor toggle",
81
+ "statusline enable",
82
+ "statusline disable",
83
+ "statusline toggle",
84
+ "copy-friendly enable",
85
+ "copy-friendly disable",
86
+ "copy-friendly toggle",
87
+ ];
88
+
89
+ const sectionLabels: Record<SettingsSection, string> = {
90
+ coloring: "Coloring",
91
+ features: "Features",
92
+ statusLine: "Status line",
93
+ };
94
+
95
+ const thirdPartyStatusSettingPrefix = "thirdPartyStatus:";
96
+
51
97
  function isColorSource(value: string): value is ColorSource {
52
98
  return value === "theme" || value === "terminal";
53
99
  }
54
100
 
55
- function isSettingId(value: string): value is SettingId {
101
+ function isColorSettingId(value: string): value is ColorSettingId {
56
102
  return value === "starship" || value === "editorMessages";
57
103
  }
58
104
 
105
+ function isFeatureSettingId(value: string): value is FeatureSettingId {
106
+ return value === "editor" || value === "statusLine" || value === "copyFriendly";
107
+ }
108
+
109
+ function isFeatureState(value: string): value is FeatureState {
110
+ return value === "enabled" || value === "disabled";
111
+ }
112
+
59
113
  function editorMessageValue(config: PolishedTuiConfig): ColorSource | "mixed" {
60
114
  return config.colorSources.editor === config.colorSources.userMessages
61
115
  ? config.colorSources.editor
62
116
  : "mixed";
63
117
  }
64
118
 
65
- function patchForSetting(id: SettingId, value: ColorSource): Partial<ColorSourcesConfig> {
119
+ function patchForSetting(id: ColorSettingId, value: ColorSource): Partial<ColorSourcesConfig> {
66
120
  return id === "starship" ? { starship: value } : { editor: value, userMessages: value };
67
121
  }
68
122
 
123
+ function featureValue(enabled: boolean): FeatureState {
124
+ return enabled ? "enabled" : "disabled";
125
+ }
126
+
127
+ function featurePatch(id: FeatureSettingId, value: FeatureState): Partial<UiFeaturesConfig> {
128
+ return { [id]: value === "enabled" } as Partial<UiFeaturesConfig>;
129
+ }
130
+
131
+ function usageText(): string {
132
+ return "Usage: /zentui [editor|statusline|copy-friendly] [enable|disable|toggle]";
133
+ }
134
+
135
+ function featureNotification(
136
+ feature: FeatureSettingId,
137
+ value: FeatureState,
138
+ result: { applied: boolean; reason?: string },
139
+ ): string {
140
+ const base = `${featureSettingLabels[feature]}: ${value}`;
141
+ return result.applied ? base : `${base} (${result.reason ?? "reload Pi to apply this change"})`;
142
+ }
143
+
144
+ function parseDirectFeatureCommand(
145
+ args: string,
146
+ config: PolishedTuiConfig,
147
+ ): { feature: FeatureSettingId; enabled: boolean } | undefined {
148
+ const normalized = args.trim().toLowerCase().replaceAll(/[_-]+/g, " ");
149
+ if (!normalized) return undefined;
150
+
151
+ const words = normalized.split(/\s+/g).filter(Boolean);
152
+ const hasWord = (value: string) => words.includes(value);
153
+ const feature = hasWord("editor")
154
+ ? "editor"
155
+ : hasWord("footer") || hasWord("statusline") || hasWord("status")
156
+ ? "statusLine"
157
+ : hasWord("copyfriendly") || hasWord("copy")
158
+ ? "copyFriendly"
159
+ : undefined;
160
+ const action = hasWord("toggle")
161
+ ? "toggle"
162
+ : hasWord("enable") || hasWord("enabled") || hasWord("on")
163
+ ? "enable"
164
+ : hasWord("disable") || hasWord("disabled") || hasWord("off")
165
+ ? "disable"
166
+ : undefined;
167
+
168
+ if (!feature || !action) return undefined;
169
+
170
+ return {
171
+ feature,
172
+ enabled: action === "toggle" ? !config.features[feature] : action === "enable",
173
+ };
174
+ }
175
+
176
+ function argumentCompletions(prefix: string): AutocompleteItem[] | null {
177
+ const trimmedPrefix = prefix.trimStart().toLowerCase();
178
+ const items = directCommandSuggestions.map((value) => ({ value, label: value }));
179
+ const matches = items.filter((item) => item.value.startsWith(trimmedPrefix));
180
+ return matches.length > 0 ? matches : null;
181
+ }
182
+
69
183
  function buildItems(
184
+ section: SettingsSection,
70
185
  config: PolishedTuiConfig,
71
- activeStatusCount: number,
72
- thirdPartyStatusesSubmenu: SettingItem["submenu"],
186
+ activeStatuses: ReadonlyMap<string, string>,
73
187
  ): SettingItem[] {
74
- return [
75
- ...(Object.keys(settingLabels) as SettingId[]).map((key) => ({
188
+ if (section === "coloring") {
189
+ return (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
76
190
  id: key,
77
- label: settingLabels[key],
78
- description: settingDescriptions[key],
191
+ label: colorSettingLabels[key],
192
+ description: colorSettingDescriptions[key],
79
193
  currentValue: key === "starship" ? config.colorSources.starship : editorMessageValue(config),
80
194
  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
- ];
195
+ }));
196
+ }
197
+
198
+ if (section === "features") {
199
+ return (Object.keys(featureSettingLabels) as FeatureSettingId[]).map((key) => ({
200
+ id: key,
201
+ label: featureSettingLabels[key],
202
+ description: featureSettingDescriptions[key],
203
+ currentValue: featureValue(config.features[key]),
204
+ values: featureStateValues,
205
+ }));
206
+ }
207
+
208
+ const statuses = Array.from(activeStatuses.entries()).sort(([a], [b]) =>
209
+ a < b ? -1 : a > b ? 1 : 0,
210
+ );
211
+ if (statuses.length === 0) {
212
+ return [
213
+ {
214
+ id: "noThirdPartyStatuses",
215
+ label: "No active statuses",
216
+ description:
217
+ "This section only lists statuses currently published through ctx.ui.setStatus().",
218
+ currentValue: "—",
219
+ },
220
+ ];
221
+ }
222
+
223
+ return statuses.map(([key, value]) => {
224
+ const sanitizedText = sanitizeExtensionStatusText(value);
225
+ return {
226
+ id: `${thirdPartyStatusSettingPrefix}${key}`,
227
+ label: key,
228
+ description: sanitizedText ? `Current status: ${sanitizedText}` : undefined,
229
+ currentValue: getExtensionStatusPlacement(config, key),
230
+ values: extensionStatusPlacementValues,
231
+ };
232
+ });
233
+ }
234
+
235
+ function thirdPartyStatusKeyFromSettingId(id: string): string | undefined {
236
+ return id.startsWith(thirdPartyStatusSettingPrefix)
237
+ ? id.slice(thirdPartyStatusSettingPrefix.length)
238
+ : undefined;
239
+ }
240
+
241
+ function nextSection(section: SettingsSection): SettingsSection {
242
+ const currentIndex = settingsSections.indexOf(section);
243
+ return settingsSections[(currentIndex + 1) % settingsSections.length] ?? "coloring";
244
+ }
245
+
246
+ function formatSectionTabs(
247
+ activeSection: SettingsSection,
248
+ theme: ExtensionContext["ui"]["theme"],
249
+ ): string {
250
+ const rendered = settingsSections.map((section) => {
251
+ const label = sectionLabels[section];
252
+ return section === activeSection ? theme.bold(label) : safeThemeFg(theme, "muted", label);
253
+ });
254
+ return ` ${rendered.join(safeThemeFg(theme, "muted", " / "))}`;
255
+ }
256
+
257
+ function withSectionFooter(lines: string[], theme: ExtensionContext["ui"]["theme"]): string[] {
258
+ const next = [...lines];
259
+ for (let index = next.length - 1; index >= 0; index -= 1) {
260
+ if (next[index]?.includes("Enter/Space")) {
261
+ next[index] = safeThemeFg(
262
+ theme,
263
+ "muted",
264
+ " Enter/Space to change · Tab to switch sections · Esc to close",
265
+ );
266
+ break;
267
+ }
268
+ }
269
+ return next;
91
270
  }
92
271
 
93
272
  export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
94
273
  pi.registerCommand("zentui", {
95
274
  description: "Configure Zentui",
275
+ getArgumentCompletions: argumentCompletions,
96
276
  handler: async (_args, ctx) => {
277
+ const args = typeof _args === "string" ? _args : "";
278
+ const directCommand = parseDirectFeatureCommand(args, deps.getConfig());
279
+ if (directCommand) {
280
+ try {
281
+ const result = deps.setUiFeatures(
282
+ { [directCommand.feature]: directCommand.enabled },
283
+ ctx,
284
+ );
285
+ deps.requestRender();
286
+ if (ctx.hasUI) {
287
+ ctx.ui.notify(
288
+ featureNotification(
289
+ directCommand.feature,
290
+ featureValue(directCommand.enabled),
291
+ result,
292
+ ),
293
+ "info",
294
+ );
295
+ }
296
+ } catch (error) {
297
+ const message = error instanceof Error ? error.message : String(error);
298
+ if (ctx.hasUI) ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
299
+ }
300
+ return;
301
+ }
302
+
303
+ if (args.trim()) {
304
+ if (ctx.hasUI) ctx.ui.notify(usageText(), "warning");
305
+ return;
306
+ }
307
+
97
308
  if (!ctx.hasUI) return;
98
309
 
99
310
  await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
100
311
  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,
312
+ let activeSection: SettingsSection = "coloring";
313
+ const applyFeatureChange = (id: FeatureSettingId, newValue: FeatureState) => {
314
+ const result = deps.setUiFeatures(featurePatch(id, newValue), ctx);
315
+ deps.requestRender();
316
+ ctx.ui.notify(featureNotification(id, newValue, result), "info");
317
+ tui.requestRender();
318
+ };
319
+ let settingsList: SettingsList;
320
+ const makeSettingsList = () =>
321
+ new SettingsList(
322
+ buildItems(activeSection, deps.getConfig(), deps.getActiveExtensionStatuses()),
149
323
  8,
150
324
  settingsListTheme,
151
- (key, newValue) => {
152
- if (!isExtensionStatusPlacement(newValue)) return;
153
-
325
+ (id, newValue) => {
154
326
  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();
327
+ if (isColorSettingId(id) && isColorSource(newValue)) {
328
+ deps.setColorSources(patchForSetting(id, newValue));
329
+ settingsList.updateValue(id, newValue);
330
+ deps.requestRender();
331
+ ctx.ui.notify(`${colorSettingLabels[id]}: ${newValue}`, "info");
332
+ tui.requestRender();
333
+ return;
334
+ }
335
+
336
+ if (isFeatureSettingId(id) && isFeatureState(newValue)) {
337
+ settingsList.updateValue(id, newValue);
338
+ if (id === "editor") {
339
+ done(undefined);
340
+ // Changing the editor component while ctx.ui.custom() is active clears the
341
+ // custom component without resolving it, leaving Pi's input loop stuck.
342
+ // Close the settings UI first, then apply the editor swap on the next tick.
343
+ setTimeout(() => {
344
+ try {
345
+ applyFeatureChange(id, newValue);
346
+ } catch (error) {
347
+ const message = error instanceof Error ? error.message : String(error);
348
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
349
+ }
350
+ }, 0);
351
+ return;
352
+ }
353
+
354
+ applyFeatureChange(id, newValue);
355
+ return;
356
+ }
357
+
358
+ const thirdPartyStatusKey = thirdPartyStatusKeyFromSettingId(id);
359
+ if (thirdPartyStatusKey && isExtensionStatusPlacement(newValue)) {
360
+ deps.setExtensionStatusPlacement(thirdPartyStatusKey, newValue);
361
+ settingsList.updateValue(id, newValue);
362
+ deps.requestRender();
363
+ ctx.ui.notify(`Third-party status ${thirdPartyStatusKey}: ${newValue}`, "info");
364
+ tui.requestRender();
365
+ }
160
366
  } catch (error) {
161
367
  const message = error instanceof Error ? error.message : String(error);
162
368
  ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
163
369
  }
164
370
  },
165
- () => close(undefined),
371
+ () => done(undefined),
166
372
  );
167
- return statusSettingsList;
373
+ settingsList = makeSettingsList();
374
+ const switchSection = () => {
375
+ activeSection = nextSection(activeSection);
376
+ settingsList = makeSettingsList();
377
+ tui.requestRender();
168
378
  };
169
- const settingsList = new SettingsList(
170
- buildItems(
171
- deps.getConfig(),
172
- deps.getActiveExtensionStatuses().size,
173
- makeThirdPartyStatusesSubmenu,
174
- ),
175
- 5,
176
- settingsListTheme,
177
- (id, newValue) => {
178
- if (!isSettingId(id) || !isColorSource(newValue)) return;
179
-
180
- 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();
186
- } catch (error) {
187
- const message = error instanceof Error ? error.message : String(error);
188
- ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
189
- }
190
- },
191
- () => done(undefined),
192
- );
193
379
 
194
380
  return {
195
381
  render(width: number) {
@@ -200,14 +386,13 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
200
386
  EDITOR_BORDER_STYLE,
201
387
  "─".repeat(Math.max(0, width)),
202
388
  );
203
- const header = safeThemeFg(theme, "accent", theme.bold("Zentui settings"));
204
- const hint = safeThemeFg(theme, "muted", "Enter/Space cycles values · Esc closes");
205
389
  return [
206
390
  truncateToWidth(border, width, ""),
207
- truncateToWidth(header, width, ""),
208
- truncateToWidth(hint, width, ""),
209
- "",
210
- ...settingsList.render(width),
391
+ truncateToWidth(formatSectionTabs(activeSection, theme), width, ""),
392
+ truncateToWidth(border, width, ""),
393
+ ...withSectionFooter(settingsList.render(width), theme).map((line) =>
394
+ truncateToWidth(line, width, ""),
395
+ ),
211
396
  truncateToWidth(border, width, ""),
212
397
  ];
213
398
  },
@@ -215,6 +400,10 @@ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCo
215
400
  settingsList.invalidate();
216
401
  },
217
402
  handleInput(data: string) {
403
+ if (data === "\t") {
404
+ switchSection();
405
+ return;
406
+ }
218
407
  settingsList.handleInput(data);
219
408
  },
220
409
  };
@@ -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
  }
@@ -81,7 +81,31 @@ export class PolishedEditor extends CustomEditor {
81
81
  return clampRenderedLines(super.render(width), width);
82
82
  }
83
83
 
84
- const innerWidth = width - 2;
84
+ const config = this.getConfig();
85
+ const colorSource = config.colorSources.editor;
86
+ const promptIcon = config.icons.editorPrompt;
87
+ const copyFriendlyPrompt = promptIcon
88
+ ? `${renderStyleForSourceOrFallback(
89
+ this.uiTheme,
90
+ colorSource,
91
+ config.colors.editorPrompt ?? config.colors.editorAccent,
92
+ EDITOR_ACCENT_FALLBACK,
93
+ promptIcon,
94
+ )}${this.reset} `
95
+ : "";
96
+ const copyFriendlyPromptWidth = visibleWidth(copyFriendlyPrompt);
97
+ const copyFriendlyContinuation = " ".repeat(copyFriendlyPromptWidth);
98
+ const rail = config.features.copyFriendly
99
+ ? ""
100
+ : `${renderStyleForSourceOrFallback(
101
+ this.uiTheme,
102
+ colorSource,
103
+ config.colors.editorAccent,
104
+ EDITOR_ACCENT_FALLBACK,
105
+ "│",
106
+ )}${this.reset} `;
107
+ const railWidth = config.features.copyFriendly ? copyFriendlyPromptWidth : visibleWidth(rail);
108
+ const innerWidth = Math.max(0, width - railWidth);
85
109
  const rendered = super.render(innerWidth);
86
110
  const editorInternals = this as unknown as AutocompleteEditorInternals;
87
111
  const isShowingAutocomplete =
@@ -111,8 +135,6 @@ export class PolishedEditor extends CustomEditor {
111
135
  return clampRenderedLines(rendered, width);
112
136
  }
113
137
 
114
- const config = this.getConfig();
115
- const colorSource = config.colorSources.editor;
116
138
  const editorLines = editorFrame.slice(1, -1);
117
139
  const { modelLabel, providerLabel } = this.getModelMeta();
118
140
  const model = renderStyleForSourceOrFallback(
@@ -147,13 +169,6 @@ export class PolishedEditor extends CustomEditor {
147
169
  }
148
170
  const meta = metaParts.filter(Boolean).join(safeThemeFg(this.uiTheme, "border", " "));
149
171
 
150
- const rail = `${renderStyleForSourceOrFallback(
151
- this.uiTheme,
152
- colorSource,
153
- config.colors.editorAccent,
154
- EDITOR_ACCENT_FALLBACK,
155
- "│",
156
- )}${this.reset} `;
157
172
  const top = renderStyleForSourceOrFallback(
158
173
  this.uiTheme,
159
174
  colorSource,
@@ -169,12 +184,28 @@ export class PolishedEditor extends CustomEditor {
169
184
  "─".repeat(width),
170
185
  );
171
186
  const lines = ["", ...editorLines, "", meta];
172
- const renderedLines = [
173
- top,
174
- ...lines.map((line) => `${rail}${this.fillLine(line, innerWidth)}`),
175
- bottom,
176
- ...autocompleteLines,
177
- ];
187
+ const renderedLines = config.features.copyFriendly
188
+ ? [
189
+ top,
190
+ "",
191
+ ...editorLines.map(
192
+ (line, index) =>
193
+ `${index === 0 ? copyFriendlyPrompt : copyFriendlyContinuation}${this.fillLine(
194
+ line,
195
+ innerWidth,
196
+ )}`,
197
+ ),
198
+ "",
199
+ ` ${truncateToWidth(meta, Math.max(0, width - 1), "")}`,
200
+ bottom,
201
+ ...autocompleteLines,
202
+ ]
203
+ : [
204
+ top,
205
+ ...lines.map((line) => `${rail}${this.fillLine(line, innerWidth)}`),
206
+ bottom,
207
+ ...autocompleteLines,
208
+ ];
178
209
 
179
210
  return clampRenderedLines(renderedLines, width);
180
211
  }
@@ -89,14 +89,10 @@ function fillLine(content: string, width: number): string {
89
89
  return `${truncated}${pad}`;
90
90
  }
91
91
 
92
- function renderPromptBoxLine(
93
- line: string,
94
- width: number,
95
- theme: Theme | undefined,
96
- config: PolishedTuiConfig,
97
- ): string {
98
- if (width <= 0) return "";
99
- const rail = `${
92
+ function renderPromptBoxRail(theme: Theme | undefined, config: PolishedTuiConfig): string {
93
+ if (config.features.copyFriendly) return "";
94
+
95
+ return `${
100
96
  theme
101
97
  ? renderStyleForSourceOrFallback(
102
98
  theme,
@@ -107,8 +103,21 @@ function renderPromptBoxLine(
107
103
  )
108
104
  : "│"
109
105
  } `;
106
+ }
107
+
108
+ function renderPromptBoxLine(
109
+ line: string,
110
+ width: number,
111
+ theme: Theme | undefined,
112
+ config: PolishedTuiConfig,
113
+ ): string {
114
+ if (width <= 0) return "";
115
+ const rail = renderPromptBoxRail(theme, config);
110
116
  const contentWidth = Math.max(0, width - visibleWidth(rail));
111
- return truncateToWidth(`${rail}${fillLine(line, contentWidth)}`, width, "");
117
+ const content = config.features.copyFriendly
118
+ ? truncateToWidth(line, contentWidth, "")
119
+ : fillLine(line, contentWidth);
120
+ return truncateToWidth(`${rail}${content}`, width, "");
112
121
  }
113
122
 
114
123
  function renderZentuiUserMessage(
@@ -121,19 +130,7 @@ function renderZentuiUserMessage(
121
130
  if (text === undefined) return undefined;
122
131
  if (width <= 0) return [""];
123
132
 
124
- const railWidth = visibleWidth(
125
- `${
126
- theme
127
- ? renderStyleForSourceOrFallback(
128
- theme,
129
- config.colorSources.userMessages,
130
- config.colors.editorAccent,
131
- EDITOR_ACCENT_FALLBACK,
132
- "│",
133
- )
134
- : "│"
135
- } `,
136
- );
133
+ const railWidth = visibleWidth(renderPromptBoxRail(theme, config));
137
134
  const contentWidth = Math.max(1, width - railWidth);
138
135
  const renderer = new Markdown(text, 0, 0, makeMarkdownTheme(theme), {
139
136
  color: (content) => themeFg(theme, "userMessageText", content),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-zentui",
3
- "version": "0.1.13",
3
+ "version": "0.1.15",
4
4
  "description": "A Starship-inspired statusline and Opencode-style TUI for Pi.",
5
5
  "type": "module",
6
6
  "license": "MIT",