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