@juanibiapina/pi-powerbar 0.9.1 → 0.11.0
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/package.json +9 -18
- package/src/powerbar/index.ts +125 -0
- package/src/powerbar/render.ts +216 -0
- package/src/powerbar/settings.ts +94 -0
- package/src/powerbar-context/index.ts +51 -0
- package/src/powerbar-git/index.ts +55 -0
- package/src/powerbar-model/index.ts +44 -0
- package/src/powerbar-provider/index.ts +35 -0
- package/src/powerbar-sub/index.ts +74 -0
- package/src/powerbar-tokens/index.ts +67 -0
- package/dist/powerbar/index.d.ts +0 -9
- package/dist/powerbar/index.d.ts.map +0 -1
- package/dist/powerbar/index.js +0 -95
- package/dist/powerbar/index.js.map +0 -1
- package/dist/powerbar/render.d.ts +0 -25
- package/dist/powerbar/render.d.ts.map +0 -1
- package/dist/powerbar/render.js +0 -160
- package/dist/powerbar/render.js.map +0 -1
- package/dist/powerbar/settings.d.ts +0 -17
- package/dist/powerbar/settings.d.ts.map +0 -1
- package/dist/powerbar/settings.js +0 -78
- package/dist/powerbar/settings.js.map +0 -1
- package/dist/powerbar-context/index.d.ts +0 -10
- package/dist/powerbar-context/index.d.ts.map +0 -1
- package/dist/powerbar-context/index.js +0 -45
- package/dist/powerbar-context/index.js.map +0 -1
- package/dist/powerbar-git/index.d.ts +0 -9
- package/dist/powerbar-git/index.d.ts.map +0 -1
- package/dist/powerbar-git/index.js +0 -51
- package/dist/powerbar-git/index.js.map +0 -1
- package/dist/powerbar-model/index.d.ts +0 -9
- package/dist/powerbar-model/index.d.ts.map +0 -1
- package/dist/powerbar-model/index.js +0 -36
- package/dist/powerbar-model/index.js.map +0 -1
- package/dist/powerbar-provider/index.d.ts +0 -9
- package/dist/powerbar-provider/index.d.ts.map +0 -1
- package/dist/powerbar-provider/index.js +0 -29
- package/dist/powerbar-provider/index.js.map +0 -1
- package/dist/powerbar-sub/index.d.ts +0 -19
- package/dist/powerbar-sub/index.d.ts.map +0 -1
- package/dist/powerbar-sub/index.js +0 -66
- package/dist/powerbar-sub/index.js.map +0 -1
- package/dist/powerbar-tokens/index.d.ts +0 -9
- package/dist/powerbar-tokens/index.d.ts.map +0 -1
- package/dist/powerbar-tokens/index.js +0 -58
- package/dist/powerbar-tokens/index.js.map +0 -1
package/package.json
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@juanibiapina/pi-powerbar",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "Pi extension that renders a persistent powerline status bar with left/right segments updated via events",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "dist/index.js",
|
|
7
|
-
"types": "./dist/index.d.ts",
|
|
8
6
|
"scripts": {
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"dev": "tsc -p tsconfig.build.json --watch",
|
|
12
|
-
"test": "npm run build && node --test test/*.test.js",
|
|
13
|
-
"check": "biome check --write --error-on-warnings . && tsc --noEmit",
|
|
14
|
-
"prepublishOnly": "npm run clean && npm run build && npm run check"
|
|
7
|
+
"test": "node --test --import tsx test/*.test.js",
|
|
8
|
+
"check": "biome check --write --error-on-warnings . && tsc --noEmit"
|
|
15
9
|
},
|
|
16
10
|
"files": [
|
|
17
|
-
"
|
|
11
|
+
"src/**/*",
|
|
18
12
|
"README.md"
|
|
19
13
|
],
|
|
20
14
|
"keywords": [
|
|
@@ -26,7 +20,7 @@
|
|
|
26
20
|
],
|
|
27
21
|
"pi": {
|
|
28
22
|
"extensions": [
|
|
29
|
-
"./
|
|
23
|
+
"./src",
|
|
30
24
|
"node_modules/@marckrenn/pi-sub-core/index.ts"
|
|
31
25
|
]
|
|
32
26
|
},
|
|
@@ -39,19 +33,16 @@
|
|
|
39
33
|
"engines": {
|
|
40
34
|
"node": ">=20.0.0"
|
|
41
35
|
},
|
|
42
|
-
"peerDependencies": {
|
|
43
|
-
"@mariozechner/pi-coding-agent": "*",
|
|
44
|
-
"@mariozechner/pi-tui": "*"
|
|
45
|
-
},
|
|
46
36
|
"dependencies": {
|
|
47
37
|
"@juanibiapina/pi-extension-settings": "^0.6.1",
|
|
48
38
|
"@marckrenn/pi-sub-core": "^1.5.0"
|
|
49
39
|
},
|
|
50
40
|
"devDependencies": {
|
|
51
|
-
"@biomejs/biome": "2.4.
|
|
41
|
+
"@biomejs/biome": "2.4.15",
|
|
42
|
+
"@earendil-works/pi-coding-agent": "^0.75.3",
|
|
52
43
|
"@marckrenn/pi-sub-shared": "^1.5.0",
|
|
53
|
-
"@
|
|
54
|
-
"
|
|
44
|
+
"@types/node": "^25.9.1",
|
|
45
|
+
"tsx": "^4.22.3",
|
|
55
46
|
"typescript": "^6.0.3"
|
|
56
47
|
}
|
|
57
48
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Powerbar Core Extension
|
|
3
|
+
*
|
|
4
|
+
* Listens for "powerbar:update" events from producer extensions,
|
|
5
|
+
* maintains a segment store, and renders a powerline-style widget.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
10
|
+
import type { OrderedListOption } from "@juanibiapina/pi-extension-settings";
|
|
11
|
+
import { renderBar, type Segment } from "./render.js";
|
|
12
|
+
import { loadSettings, type PowerbarSettings, registerSettings } from "./settings.js";
|
|
13
|
+
|
|
14
|
+
interface PowerbarUpdatePayload {
|
|
15
|
+
id: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
suffix?: string;
|
|
18
|
+
icon?: string;
|
|
19
|
+
color?: string;
|
|
20
|
+
bar?: number;
|
|
21
|
+
barSegments?: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface SegmentRegistration {
|
|
25
|
+
id: string;
|
|
26
|
+
label: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function segmentEquals(left: Segment | undefined, right: Segment): boolean {
|
|
30
|
+
return (
|
|
31
|
+
left?.text === right.text &&
|
|
32
|
+
left.suffix === right.suffix &&
|
|
33
|
+
left.icon === right.icon &&
|
|
34
|
+
left.color === right.color &&
|
|
35
|
+
left.bar === right.bar &&
|
|
36
|
+
left.barSegments === right.barSegments
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
41
|
+
const segments: Map<string, Segment> = new Map();
|
|
42
|
+
const segmentCatalog: Map<string, OrderedListOption> = new Map();
|
|
43
|
+
let settings: PowerbarSettings;
|
|
44
|
+
let currentCtx: { ui: { setWidget: (...args: any[]) => void }; hasUI: boolean } | undefined;
|
|
45
|
+
|
|
46
|
+
// Register settings with empty options initially (no segments known yet)
|
|
47
|
+
registerSettings(pi, []);
|
|
48
|
+
|
|
49
|
+
// Listen for segment registrations from producer extensions
|
|
50
|
+
pi.events.on("powerbar:register-segment", (data: unknown) => {
|
|
51
|
+
const { id, label } = data as SegmentRegistration;
|
|
52
|
+
segmentCatalog.set(id, { id, label });
|
|
53
|
+
// Re-register settings with updated segment options
|
|
54
|
+
registerSettings(pi, Array.from(segmentCatalog.values()));
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
function refresh(): void {
|
|
58
|
+
if (!currentCtx?.hasUI) return;
|
|
59
|
+
|
|
60
|
+
currentCtx.ui.setWidget(
|
|
61
|
+
"powerbar",
|
|
62
|
+
(_tui: TUI, theme: Theme): Component & { dispose?(): void } => {
|
|
63
|
+
return {
|
|
64
|
+
render(width: number): string[] {
|
|
65
|
+
const line = renderBar(segments, settings, theme, width);
|
|
66
|
+
return [line];
|
|
67
|
+
},
|
|
68
|
+
invalidate(): void {
|
|
69
|
+
// No cached state to clear
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
{ placement: settings.placement },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Listen for segment updates from any extension
|
|
78
|
+
pi.events.on("powerbar:update", (data: unknown) => {
|
|
79
|
+
const payload = data as PowerbarUpdatePayload;
|
|
80
|
+
if (!payload?.id) return;
|
|
81
|
+
|
|
82
|
+
if (!payload.text && payload.bar === undefined) {
|
|
83
|
+
const changed = segments.delete(payload.id);
|
|
84
|
+
if (!changed) return;
|
|
85
|
+
} else {
|
|
86
|
+
const nextSegment: Segment = {
|
|
87
|
+
id: payload.id,
|
|
88
|
+
text: payload.text ?? "",
|
|
89
|
+
suffix: payload.suffix,
|
|
90
|
+
icon: payload.icon,
|
|
91
|
+
color: payload.color,
|
|
92
|
+
bar: payload.bar,
|
|
93
|
+
barSegments: payload.barSegments,
|
|
94
|
+
};
|
|
95
|
+
if (segmentEquals(segments.get(payload.id), nextSegment)) return;
|
|
96
|
+
segments.set(payload.id, nextSegment);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
refresh();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
function hideFooter(ctx: { ui: ExtensionUIContext; hasUI: boolean }): void {
|
|
103
|
+
if (!ctx.hasUI) return;
|
|
104
|
+
ctx.ui.setFooter((_tui, _theme, _footerData) => ({
|
|
105
|
+
render(): string[] {
|
|
106
|
+
return [];
|
|
107
|
+
},
|
|
108
|
+
invalidate(): void {},
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
113
|
+
settings = loadSettings();
|
|
114
|
+
currentCtx = ctx;
|
|
115
|
+
hideFooter(ctx);
|
|
116
|
+
refresh();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
pi.on("session_shutdown", async (_event, ctx) => {
|
|
120
|
+
if (ctx.hasUI) {
|
|
121
|
+
ctx.ui.setWidget("powerbar", undefined);
|
|
122
|
+
}
|
|
123
|
+
currentCtx = undefined;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rendering logic for the powerbar.
|
|
3
|
+
*
|
|
4
|
+
* Builds a single line with left-aligned and right-aligned segments,
|
|
5
|
+
* joined by themed separators. Supports two progress bar styles:
|
|
6
|
+
* continuous (█ + partial-width glyphs ▏▎▍▌▋▊▉) and blocks
|
|
7
|
+
* (discrete partial-height glyphs ▁▂▃▄▅▆▇█ with dim background).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { PowerbarSettings } from "./settings.js";
|
|
13
|
+
|
|
14
|
+
export interface Segment {
|
|
15
|
+
id: string;
|
|
16
|
+
/** Primary text, rendered before the bar. */
|
|
17
|
+
text: string;
|
|
18
|
+
/** Text rendered after the bar (e.g., "59%"). */
|
|
19
|
+
suffix?: string;
|
|
20
|
+
icon?: string;
|
|
21
|
+
color?: string;
|
|
22
|
+
/** If set, renders a progress bar. Value is 0–100. */
|
|
23
|
+
bar?: number;
|
|
24
|
+
/** Hint for how many discrete blocks to use in blocks mode. Falls back to barWidth setting. */
|
|
25
|
+
barSegments?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Render a continuous progress bar using full-width block characters.
|
|
30
|
+
*
|
|
31
|
+
* █ for filled columns, ▏▎▍▌▋▊▉ for the partial column, space for empty.
|
|
32
|
+
*/
|
|
33
|
+
function renderProgressBar(percent: number, width: number, theme: Theme, color: string): string {
|
|
34
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
35
|
+
const filledFloat = (clamped / 100) * width;
|
|
36
|
+
const filledFull = Math.floor(filledFloat);
|
|
37
|
+
const remainder = filledFloat - filledFull;
|
|
38
|
+
|
|
39
|
+
// Partial block levels: ▏(1/8) ▎(2/8) ▍(3/8) ▌(4/8) ▋(5/8) ▊(6/8) ▉(7/8)
|
|
40
|
+
const levels = ["▏", "▎", "▍", "▌", "▋", "▊", "▉"];
|
|
41
|
+
|
|
42
|
+
const themeColor = color as ThemeColor;
|
|
43
|
+
const filledStr = "█".repeat(filledFull);
|
|
44
|
+
|
|
45
|
+
let partial = "";
|
|
46
|
+
let emptyCount = width - filledFull;
|
|
47
|
+
|
|
48
|
+
if (remainder >= 0.0625 && filledFull < width) {
|
|
49
|
+
const levelIndex = Math.max(0, Math.min(levels.length - 1, Math.round(remainder * 8) - 1));
|
|
50
|
+
partial = levels[levelIndex];
|
|
51
|
+
emptyCount = Math.max(0, emptyCount - 1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const emptyStr = " ".repeat(emptyCount);
|
|
55
|
+
|
|
56
|
+
return theme.fg(themeColor, filledStr + partial) + emptyStr;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Convert a foreground ANSI escape to background by replacing SGR 38 with 48. */
|
|
60
|
+
function fgToBgAnsi(fgAnsi: string): string {
|
|
61
|
+
return fgAnsi.replace("\x1b[38;", "\x1b[48;");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Render a bar of discrete block characters with a dim background track.
|
|
66
|
+
*
|
|
67
|
+
* Splits the 0–100 percent range evenly across `segments` blocks and
|
|
68
|
+
* computes a fill level (0–8) per block. The dim theme color provides
|
|
69
|
+
* the background "track"; partial block glyphs fill from the bottom
|
|
70
|
+
* in the segment color.
|
|
71
|
+
*/
|
|
72
|
+
function renderBlocksBar(percent: number, segments: number, theme: Theme, color: string): string {
|
|
73
|
+
const glyphs = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
|
|
74
|
+
const dimBg = fgToBgAnsi(theme.getFgAnsi("dim"));
|
|
75
|
+
const fgColor = theme.getFgAnsi((color || "muted") as ThemeColor);
|
|
76
|
+
const reset = "\x1b[39m\x1b[49m";
|
|
77
|
+
const clamped = Math.max(0, Math.min(100, percent));
|
|
78
|
+
const filledFloat = (clamped / 100) * segments;
|
|
79
|
+
|
|
80
|
+
const result: string[] = [];
|
|
81
|
+
for (let i = 0; i < segments; i++) {
|
|
82
|
+
const blockFill = Math.max(0, Math.min(1, filledFloat - i));
|
|
83
|
+
const level = Math.round(blockFill * 8);
|
|
84
|
+
const glyph = glyphs[level];
|
|
85
|
+
result.push(level > 0 ? `${dimBg}${fgColor}${glyph}${reset}` : `${dimBg}${glyph}${reset}`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result.join(" ");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Render a single segment.
|
|
93
|
+
*
|
|
94
|
+
* Layout: [icon] [text] [bar] [suffix]
|
|
95
|
+
*/
|
|
96
|
+
function renderSegmentText(segment: Segment, settings: PowerbarSettings, theme: Theme): string {
|
|
97
|
+
const parts: string[] = [];
|
|
98
|
+
const themeColor = (segment.color || "muted") as ThemeColor;
|
|
99
|
+
|
|
100
|
+
if (segment.icon) {
|
|
101
|
+
parts.push(theme.fg(themeColor, segment.icon));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (segment.text) {
|
|
105
|
+
parts.push(theme.fg(themeColor, segment.text));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (segment.bar !== undefined) {
|
|
109
|
+
const color = segment.color || "muted";
|
|
110
|
+
if (settings.barStyle === "blocks") {
|
|
111
|
+
const blockCount = segment.barSegments ?? settings.barWidth;
|
|
112
|
+
parts.push(renderBlocksBar(segment.bar, blockCount, theme, color));
|
|
113
|
+
} else {
|
|
114
|
+
parts.push(renderProgressBar(segment.bar, settings.barWidth, theme, color));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (segment.suffix) {
|
|
119
|
+
parts.push(theme.fg(themeColor, segment.suffix));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return parts.join(" ");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
interface RenderedSegment {
|
|
126
|
+
text: string;
|
|
127
|
+
width: number;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function renderSideSegments(
|
|
131
|
+
ids: string[],
|
|
132
|
+
segments: Map<string, Segment>,
|
|
133
|
+
settings: PowerbarSettings,
|
|
134
|
+
theme: Theme,
|
|
135
|
+
): RenderedSegment[] {
|
|
136
|
+
const rendered: RenderedSegment[] = [];
|
|
137
|
+
for (const id of ids) {
|
|
138
|
+
const seg = segments.get(id);
|
|
139
|
+
if (!seg || (!seg.text && !seg.suffix && seg.bar === undefined)) continue;
|
|
140
|
+
const text = renderSegmentText(seg, settings, theme);
|
|
141
|
+
rendered.push({ text, width: visibleWidth(text) });
|
|
142
|
+
}
|
|
143
|
+
return rendered;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function joinSegments(segments: RenderedSegment[], separator: string, separatorWidth: number): RenderedSegment {
|
|
147
|
+
if (segments.length === 0) return { text: "", width: 0 };
|
|
148
|
+
const text = segments.map((s) => s.text).join(separator);
|
|
149
|
+
const width = segments.reduce((sum, s) => sum + s.width, 0) + separatorWidth * (segments.length - 1);
|
|
150
|
+
return { text, width };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Truncate the widest segment to reclaim overflow space.
|
|
155
|
+
* Mutates the array in place and returns the new total width.
|
|
156
|
+
*/
|
|
157
|
+
function shrinkWidest(segments: RenderedSegment[], overflow: number): void {
|
|
158
|
+
if (segments.length === 0) return;
|
|
159
|
+
|
|
160
|
+
let widestIdx = 0;
|
|
161
|
+
for (let i = 1; i < segments.length; i++) {
|
|
162
|
+
if (segments[i].width > segments[widestIdx].width) {
|
|
163
|
+
widestIdx = i;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const seg = segments[widestIdx];
|
|
168
|
+
const targetWidth = Math.max(1, seg.width - overflow);
|
|
169
|
+
segments[widestIdx] = {
|
|
170
|
+
text: truncateToWidth(seg.text, targetWidth, "…"),
|
|
171
|
+
width: targetWidth,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function renderBar(
|
|
176
|
+
segments: Map<string, Segment>,
|
|
177
|
+
settings: PowerbarSettings,
|
|
178
|
+
theme: Theme,
|
|
179
|
+
width: number,
|
|
180
|
+
): string {
|
|
181
|
+
const separator = theme.fg("dim", settings.separator);
|
|
182
|
+
const separatorWidth = visibleWidth(separator);
|
|
183
|
+
|
|
184
|
+
const leftSegs = renderSideSegments(settings.left, segments, settings, theme);
|
|
185
|
+
const rightSegs = renderSideSegments(settings.right, segments, settings, theme);
|
|
186
|
+
const allSegs = [...leftSegs, ...rightSegs];
|
|
187
|
+
|
|
188
|
+
// Calculate total content width (segments + separators within each side + 1 for minimum padding)
|
|
189
|
+
const leftSepCount = Math.max(0, leftSegs.length - 1);
|
|
190
|
+
const rightSepCount = Math.max(0, rightSegs.length - 1);
|
|
191
|
+
const totalSepWidth = (leftSepCount + rightSepCount) * separatorWidth;
|
|
192
|
+
const totalSegWidth = allSegs.reduce((sum, s) => sum + s.width, 0);
|
|
193
|
+
const minPadding = 1;
|
|
194
|
+
const totalNeeded = totalSegWidth + totalSepWidth + minPadding;
|
|
195
|
+
|
|
196
|
+
// Shrink the widest segment(s) until it fits
|
|
197
|
+
if (totalNeeded > width) {
|
|
198
|
+
let overflow = totalNeeded - width;
|
|
199
|
+
const maxPasses = allSegs.length;
|
|
200
|
+
for (let i = 0; i < maxPasses && overflow > 0; i++) {
|
|
201
|
+
shrinkWidest(allSegs, overflow);
|
|
202
|
+
const newSegWidth = allSegs.reduce((sum, s) => sum + s.width, 0);
|
|
203
|
+
overflow = newSegWidth + totalSepWidth + minPadding - width;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Rebuild left/right from the (possibly truncated) segments
|
|
208
|
+
const left = joinSegments(allSegs.slice(0, leftSegs.length), separator, separatorWidth);
|
|
209
|
+
const right = joinSegments(allSegs.slice(leftSegs.length), separator, separatorWidth);
|
|
210
|
+
|
|
211
|
+
const padding = Math.max(minPadding, width - left.width - right.width);
|
|
212
|
+
const line = `${left.text}${" ".repeat(padding)}${right.text}`;
|
|
213
|
+
|
|
214
|
+
// Safety net
|
|
215
|
+
return truncateToWidth(line, width, "…");
|
|
216
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings for the powerbar via pi-extension-settings.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { OrderedListOption, SettingDefinition } from "@juanibiapina/pi-extension-settings";
|
|
7
|
+
import { getSetting } from "@juanibiapina/pi-extension-settings";
|
|
8
|
+
|
|
9
|
+
export const EXTENSION_NAME = "powerbar";
|
|
10
|
+
|
|
11
|
+
export interface PowerbarSettings {
|
|
12
|
+
left: string[];
|
|
13
|
+
right: string[];
|
|
14
|
+
separator: string;
|
|
15
|
+
placement: "aboveEditor" | "belowEditor";
|
|
16
|
+
barWidth: number;
|
|
17
|
+
barStyle: "continuous" | "blocks";
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function registerSettings(pi: ExtensionAPI, segmentOptions: OrderedListOption[]): void {
|
|
21
|
+
const definitions: SettingDefinition[] = [
|
|
22
|
+
{
|
|
23
|
+
id: "left",
|
|
24
|
+
label: "Left segments",
|
|
25
|
+
description: "Segments shown on the left side of the powerbar",
|
|
26
|
+
defaultValue: "git-branch,tokens,context-usage",
|
|
27
|
+
options: segmentOptions,
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
id: "right",
|
|
31
|
+
label: "Right segments",
|
|
32
|
+
description: "Segments shown on the right side of the powerbar",
|
|
33
|
+
defaultValue: "provider,model,sub-hourly,sub-weekly",
|
|
34
|
+
options: segmentOptions,
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: "separator",
|
|
38
|
+
label: "Separator",
|
|
39
|
+
description: "Separator between segments",
|
|
40
|
+
defaultValue: " │ ",
|
|
41
|
+
values: [" │ ", " ┃ ", " | ", " · ", " "],
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: "placement",
|
|
45
|
+
label: "Placement",
|
|
46
|
+
description: "Where the powerbar appears",
|
|
47
|
+
defaultValue: "belowEditor",
|
|
48
|
+
values: ["belowEditor", "aboveEditor"],
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
id: "bar-style",
|
|
52
|
+
label: "Bar style",
|
|
53
|
+
description: "Visual style of progress bars",
|
|
54
|
+
defaultValue: "blocks",
|
|
55
|
+
values: ["continuous", "blocks"],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: "bar-width",
|
|
59
|
+
label: "Bar width",
|
|
60
|
+
description: "Width of progress bars in characters",
|
|
61
|
+
defaultValue: "10",
|
|
62
|
+
values: ["6", "8", "10", "12", "16"],
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
pi.events.emit("pi-extension-settings:register", {
|
|
67
|
+
name: EXTENSION_NAME,
|
|
68
|
+
settings: definitions,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function loadSettings(): PowerbarSettings {
|
|
73
|
+
const leftStr = getSetting(EXTENSION_NAME, "left", "git-branch,tokens,context-usage") ?? "";
|
|
74
|
+
const rightStr = getSetting(EXTENSION_NAME, "right", "provider,model,sub-hourly,sub-weekly") ?? "";
|
|
75
|
+
const separator = getSetting(EXTENSION_NAME, "separator", " │ ") ?? " │ ";
|
|
76
|
+
const placement = getSetting(EXTENSION_NAME, "placement", "belowEditor") ?? "belowEditor";
|
|
77
|
+
const barStyle = getSetting(EXTENSION_NAME, "bar-style", "blocks") ?? "blocks";
|
|
78
|
+
const barWidthStr = getSetting(EXTENSION_NAME, "bar-width", "10") ?? "10";
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
left: leftStr
|
|
82
|
+
.split(",")
|
|
83
|
+
.map((s) => s.trim())
|
|
84
|
+
.filter(Boolean),
|
|
85
|
+
right: rightStr
|
|
86
|
+
.split(",")
|
|
87
|
+
.map((s) => s.trim())
|
|
88
|
+
.filter(Boolean),
|
|
89
|
+
separator,
|
|
90
|
+
placement: placement === "aboveEditor" ? "aboveEditor" : "belowEditor",
|
|
91
|
+
barStyle: barStyle === "continuous" ? "continuous" : "blocks",
|
|
92
|
+
barWidth: Math.max(4, Math.min(24, Number.parseInt(barWidthStr, 10) || 10)),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Powerbar Context Producer
|
|
3
|
+
*
|
|
4
|
+
* Shows context window usage as a progress bar with percentage.
|
|
5
|
+
* Color changes based on usage level: accent → warning → error.
|
|
6
|
+
* Segment ID: "context-usage"
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const CHUNK_SIZE = 100_000;
|
|
12
|
+
|
|
13
|
+
function getColor(pct: number): string {
|
|
14
|
+
if (pct > 80) return "error";
|
|
15
|
+
if (pct > 60) return "warning";
|
|
16
|
+
return "muted";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function emitContextUsage(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
20
|
+
const usage = ctx.getContextUsage();
|
|
21
|
+
if (usage && usage.tokens != null) {
|
|
22
|
+
const pct = Math.round((usage.tokens / usage.contextWindow) * 100);
|
|
23
|
+
pi.events.emit("powerbar:update", {
|
|
24
|
+
id: "context-usage",
|
|
25
|
+
text: "",
|
|
26
|
+
suffix: `${pct}%`,
|
|
27
|
+
bar: pct,
|
|
28
|
+
barSegments: Math.ceil(usage.contextWindow / CHUNK_SIZE),
|
|
29
|
+
color: getColor(pct),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function resetContextUsage(pi: ExtensionAPI): void {
|
|
35
|
+
pi.events.emit("powerbar:update", {
|
|
36
|
+
id: "context-usage",
|
|
37
|
+
text: undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
42
|
+
pi.events.emit("powerbar:register-segment", { id: "context-usage", label: "Context Usage" });
|
|
43
|
+
|
|
44
|
+
// Reset on new/switched session
|
|
45
|
+
pi.on("session_start", async () => resetContextUsage(pi));
|
|
46
|
+
|
|
47
|
+
// Update frequently during agent work
|
|
48
|
+
pi.on("turn_start", async (_event, ctx) => emitContextUsage(pi, ctx));
|
|
49
|
+
pi.on("tool_result", async (_event, ctx) => emitContextUsage(pi, ctx));
|
|
50
|
+
pi.on("turn_end", async (_event, ctx) => emitContextUsage(pi, ctx));
|
|
51
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Powerbar Git Producer
|
|
3
|
+
*
|
|
4
|
+
* Shows the current git branch.
|
|
5
|
+
* Segment ID: "git-branch"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { readFileSync } from "fs";
|
|
10
|
+
import { join } from "path";
|
|
11
|
+
|
|
12
|
+
function getGitBranch(cwd: string): string | undefined {
|
|
13
|
+
try {
|
|
14
|
+
const head = readFileSync(join(cwd, ".git", "HEAD"), "utf-8").trim();
|
|
15
|
+
if (head.startsWith("ref: refs/heads/")) {
|
|
16
|
+
return head.slice(16);
|
|
17
|
+
}
|
|
18
|
+
// Detached HEAD — show short hash
|
|
19
|
+
return head.slice(0, 8);
|
|
20
|
+
} catch {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function emitBranch(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
26
|
+
const branch = getGitBranch(ctx.cwd);
|
|
27
|
+
if (branch) {
|
|
28
|
+
pi.events.emit("powerbar:update", {
|
|
29
|
+
id: "git-branch",
|
|
30
|
+
text: branch,
|
|
31
|
+
icon: "⎇",
|
|
32
|
+
color: "muted",
|
|
33
|
+
});
|
|
34
|
+
} else {
|
|
35
|
+
pi.events.emit("powerbar:update", {
|
|
36
|
+
id: "git-branch",
|
|
37
|
+
text: undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
43
|
+
pi.events.emit("powerbar:register-segment", { id: "git-branch", label: "Git Branch" });
|
|
44
|
+
|
|
45
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
46
|
+
emitBranch(pi, ctx);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// Refresh after bash commands (user may have changed branches)
|
|
50
|
+
pi.on("tool_result", async (event, ctx) => {
|
|
51
|
+
if (event.toolName === "bash") {
|
|
52
|
+
emitBranch(pi, ctx);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Powerbar Model Producer
|
|
3
|
+
*
|
|
4
|
+
* Shows the current model name and thinking level.
|
|
5
|
+
* Segment ID: "model"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
function emitModel(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
11
|
+
const model = ctx.model;
|
|
12
|
+
if (!model) return;
|
|
13
|
+
|
|
14
|
+
const modelId = model.id;
|
|
15
|
+
let text = modelId;
|
|
16
|
+
|
|
17
|
+
// Add thinking level if model supports reasoning
|
|
18
|
+
if (model.reasoning) {
|
|
19
|
+
const level = pi.getThinkingLevel();
|
|
20
|
+
text = level === "off" ? `${modelId} · off` : `${modelId} · ${level}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
pi.events.emit("powerbar:update", {
|
|
24
|
+
id: "model",
|
|
25
|
+
text,
|
|
26
|
+
color: "dim",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
31
|
+
pi.events.emit("powerbar:register-segment", { id: "model", label: "Model" });
|
|
32
|
+
|
|
33
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
34
|
+
emitModel(pi, ctx);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
38
|
+
emitModel(pi, ctx);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
42
|
+
emitModel(pi, ctx);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Powerbar Provider Producer
|
|
3
|
+
*
|
|
4
|
+
* Shows the current LLM provider name.
|
|
5
|
+
* Segment ID: "provider"
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
function emitProvider(pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
11
|
+
const model = ctx.model;
|
|
12
|
+
if (!model) return;
|
|
13
|
+
|
|
14
|
+
pi.events.emit("powerbar:update", {
|
|
15
|
+
id: "provider",
|
|
16
|
+
text: model.provider,
|
|
17
|
+
color: "dim",
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export default function createExtension(pi: ExtensionAPI): void {
|
|
22
|
+
pi.events.emit("powerbar:register-segment", { id: "provider", label: "Provider" });
|
|
23
|
+
|
|
24
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
25
|
+
emitProvider(pi, ctx);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
pi.on("model_select", async (_event, ctx) => {
|
|
29
|
+
emitProvider(pi, ctx);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
33
|
+
emitProvider(pi, ctx);
|
|
34
|
+
});
|
|
35
|
+
}
|