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 +34 -5
- package/extensions/zentui/config.ts +66 -0
- package/extensions/zentui/format.ts +31 -3
- package/extensions/zentui/index.ts +137 -34
- package/extensions/zentui/settings-command.ts +299 -110
- package/extensions/zentui/state.ts +2 -3
- package/extensions/zentui/ui.ts +47 -16
- package/extensions/zentui/user-message.ts +19 -22
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
96
|
-
if (
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
|
51
|
+
const colorSettingLabels: Record<ColorSettingId, string> = {
|
|
40
52
|
starship: "Starship/footer colors",
|
|
41
53
|
editorMessages: "Editor + previous messages",
|
|
42
54
|
};
|
|
43
55
|
|
|
44
|
-
const
|
|
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
|
|
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:
|
|
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
|
-
|
|
72
|
-
thirdPartyStatusesSubmenu: SettingItem["submenu"],
|
|
186
|
+
activeStatuses: ReadonlyMap<string, string>,
|
|
73
187
|
): SettingItem[] {
|
|
74
|
-
|
|
75
|
-
|
|
188
|
+
if (section === "coloring") {
|
|
189
|
+
return (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
|
|
76
190
|
id: key,
|
|
77
|
-
label:
|
|
78
|
-
description:
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
(
|
|
152
|
-
if (!isExtensionStatusPlacement(newValue)) return;
|
|
153
|
-
|
|
325
|
+
(id, newValue) => {
|
|
154
326
|
try {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
() =>
|
|
371
|
+
() => done(undefined),
|
|
166
372
|
);
|
|
167
|
-
|
|
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(
|
|
208
|
-
truncateToWidth(
|
|
209
|
-
|
|
210
|
-
|
|
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
|
}
|
package/extensions/zentui/ui.ts
CHANGED
|
@@ -81,7 +81,31 @@ export class PolishedEditor extends CustomEditor {
|
|
|
81
81
|
return clampRenderedLines(super.render(width), width);
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
const
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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),
|