agy-powerline 1.2.0 → 1.3.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/CHANGELOG.md +34 -0
- package/README.fr.md +190 -0
- package/README.ja.md +68 -26
- package/README.md +84 -29
- package/README.zh-CN.md +80 -38
- package/package.json +3 -2
- package/src/cli/caps.js +2 -0
- package/src/cli/configure.js +8 -144
- package/src/cli/doctor.js +12 -0
- package/src/cli/export.js +82 -0
- package/src/cli/gradient.js +121 -0
- package/src/cli/import.js +273 -0
- package/src/cli/init.js +186 -7
- package/src/cli/layout.js +2 -0
- package/src/cli/preset.js +2 -0
- package/src/cli/repo-url.js +7 -0
- package/src/cli/theme.js +2 -0
- package/src/cli/uninstall.js +6 -2
- package/src/index.js +51 -0
- package/src/lib/catalog.js +54 -2
- package/src/lib/colors.js +127 -0
- package/src/lib/config-check.js +74 -0
- package/src/lib/config.js +20 -1
- package/src/lib/exec.js +140 -21
- package/src/lib/fit.js +7 -0
- package/src/lib/formatters.js +67 -0
- package/src/lib/git.js +374 -2
- package/src/lib/layouts/lines.js +139 -20
- package/src/lib/layouts/pills.js +167 -18
- package/src/lib/paths.js +73 -13
- package/src/lib/prompt.js +30 -0
- package/src/lib/run.js +2 -0
- package/src/lib/session.js +17 -3
- package/src/lib/shared.js +11 -1
- package/src/lib/themes.js +79 -5
- package/src/lib/usage.js +524 -0
- package/src/lib/widget-options.js +194 -0
- package/src/lib/widget-style.js +39 -0
- package/src/runtime/statusline.js +15 -1
package/src/lib/catalog.js
CHANGED
|
@@ -126,8 +126,45 @@ const WIDGET_CATALOG = [
|
|
|
126
126
|
id: "git",
|
|
127
127
|
label: "VCS Branch",
|
|
128
128
|
unique: true,
|
|
129
|
-
description:
|
|
130
|
-
|
|
129
|
+
description:
|
|
130
|
+
"current branch, marked when there are uncommitted changes, with how many commits it is ahead of or behind its upstream",
|
|
131
|
+
summary: "branch, changes and ahead/behind",
|
|
132
|
+
},
|
|
133
|
+
{
|
|
134
|
+
id: "gitChanges",
|
|
135
|
+
label: "Git Line Changes",
|
|
136
|
+
unique: true,
|
|
137
|
+
description:
|
|
138
|
+
"lines added and removed since the last commit, staged or not (new files that aren't staged don't count)",
|
|
139
|
+
summary: "lines added and removed",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: "gitFiles",
|
|
143
|
+
label: "Git File Counts",
|
|
144
|
+
unique: true,
|
|
145
|
+
description: "how many files are staged, modified, untracked and in conflict",
|
|
146
|
+
summary: "staged, modified, untracked file counts",
|
|
147
|
+
},
|
|
148
|
+
{
|
|
149
|
+
id: "gitSha",
|
|
150
|
+
label: "Git Commit",
|
|
151
|
+
unique: true,
|
|
152
|
+
description: "short ID of the commit checked out",
|
|
153
|
+
summary: "short commit ID",
|
|
154
|
+
},
|
|
155
|
+
{
|
|
156
|
+
id: "gitStash",
|
|
157
|
+
label: "Git Stash Count",
|
|
158
|
+
unique: true,
|
|
159
|
+
description: "how many stashes the repository holds; hidden when there are none",
|
|
160
|
+
summary: "number of stashes",
|
|
161
|
+
},
|
|
162
|
+
{
|
|
163
|
+
id: "gitWorktree",
|
|
164
|
+
label: "Git Worktree",
|
|
165
|
+
unique: true,
|
|
166
|
+
description: "name of the linked worktree you're in; hidden in the main one",
|
|
167
|
+
summary: "linked worktree name",
|
|
131
168
|
},
|
|
132
169
|
{
|
|
133
170
|
id: "plan",
|
|
@@ -220,6 +257,21 @@ const WIDGET_CATALOG = [
|
|
|
220
257
|
description: "session tokens, as a total or split into input and output",
|
|
221
258
|
summary: "session tokens, total or split",
|
|
222
259
|
},
|
|
260
|
+
{
|
|
261
|
+
id: "tokensToday",
|
|
262
|
+
label: "Tokens Today",
|
|
263
|
+
unique: true,
|
|
264
|
+
description: "tokens used today in all agy conversations, from agy's local data (Node 22.13+)",
|
|
265
|
+
summary: "tokens used today, all conversations",
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
id: "tokensWeek",
|
|
269
|
+
label: "Tokens 7 Days",
|
|
270
|
+
unique: true,
|
|
271
|
+
description:
|
|
272
|
+
"tokens used in the last 7 days in all agy conversations, from agy's local data (Node 22.13+)",
|
|
273
|
+
summary: "tokens used over 7 days",
|
|
274
|
+
},
|
|
223
275
|
{
|
|
224
276
|
id: "exec",
|
|
225
277
|
label: "Shell Command",
|
package/src/lib/colors.js
CHANGED
|
@@ -196,6 +196,122 @@ function parseColor(value, fallback = [200, 200, 200]) {
|
|
|
196
196
|
return fallback;
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
+
function linear(c) {
|
|
200
|
+
const v = c / 255;
|
|
201
|
+
return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function gamma(v) {
|
|
205
|
+
const c = v <= 0.0031308 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
|
|
206
|
+
return Math.max(0, Math.min(255, Math.round(c * 255)));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// WCAG relative luminance and contrast ratio.
|
|
210
|
+
function luminance([r, g, b]) {
|
|
211
|
+
return 0.2126 * linear(r) + 0.7152 * linear(g) + 0.0722 * linear(b);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function contrastRatio(a, b) {
|
|
215
|
+
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
|
216
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// OKLab (Björn Ottosson): mixing there keeps the middle of a gradient as
|
|
220
|
+
// bright and saturated as its ends, where sRGB mixing turns it muddy.
|
|
221
|
+
function toOklab([r, g, b]) {
|
|
222
|
+
const [lr, lg, lb] = [linear(r), linear(g), linear(b)];
|
|
223
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
224
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
225
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
226
|
+
return [
|
|
227
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
228
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
229
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
230
|
+
];
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function fromOklab([L, a, b]) {
|
|
234
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
235
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
236
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
237
|
+
return [
|
|
238
|
+
gamma(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
|
|
239
|
+
gamma(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
|
|
240
|
+
gamma(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
|
|
241
|
+
];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const MAX_GRADIENT_STOPS = 6;
|
|
245
|
+
const HEX_STOP = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
246
|
+
|
|
247
|
+
// "#ff5f6d-#ffc371" (2 to 6 hex stops, "#" optional) to RGB stops, or null.
|
|
248
|
+
function parseGradientStops(spec) {
|
|
249
|
+
if (typeof spec !== "string") {
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
const parts = spec.trim().split("-");
|
|
253
|
+
if (parts.length < 2 || parts.length > MAX_GRADIENT_STOPS) {
|
|
254
|
+
return null;
|
|
255
|
+
}
|
|
256
|
+
if (!parts.every((part) => HEX_STOP.test(part))) {
|
|
257
|
+
return null;
|
|
258
|
+
}
|
|
259
|
+
return parts.map((part) => parseColor(part));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// The color t (0 to 1) of the way along the stops; the ends are exact.
|
|
263
|
+
function gradientAt(stops, t) {
|
|
264
|
+
const clamped = Math.max(0, Math.min(1, Number(t) || 0));
|
|
265
|
+
const span = clamped * (stops.length - 1);
|
|
266
|
+
const i = Math.min(stops.length - 2, Math.floor(span));
|
|
267
|
+
const local = span - i;
|
|
268
|
+
if (local === 0) {
|
|
269
|
+
return [...stops[i]];
|
|
270
|
+
}
|
|
271
|
+
if (local === 1) {
|
|
272
|
+
return [...stops[i + 1]];
|
|
273
|
+
}
|
|
274
|
+
const [a, b] = [toOklab(stops[i]), toOklab(stops[i + 1])];
|
|
275
|
+
return fromOklab(a.map((value, k) => value + (b[k] - value) * local));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// count evenly spaced colors from the first stop to the last.
|
|
279
|
+
function gradientColors(stops, count) {
|
|
280
|
+
if (count <= 1) {
|
|
281
|
+
return count === 1 ? [[...stops[0]]] : [];
|
|
282
|
+
}
|
|
283
|
+
return Array.from({ length: count }, (_, i) => gradientAt(stops, i / (count - 1)));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const MIN_CONTRAST = 4.5;
|
|
287
|
+
|
|
288
|
+
// The preferred text color if it reads on the background, else whichever of
|
|
289
|
+
// the fallbacks contrasts most.
|
|
290
|
+
function readableText(background, preferred, fallbacks) {
|
|
291
|
+
if (contrastRatio(preferred, background) >= MIN_CONTRAST) {
|
|
292
|
+
return preferred;
|
|
293
|
+
}
|
|
294
|
+
return fallbacks.reduce((best, color) =>
|
|
295
|
+
contrastRatio(color, background) > contrastRatio(best, background) ? color : best
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// The color moved toward white (on a dark background) or black (on a light
|
|
300
|
+
// one) just far enough to reach the minimum contrast.
|
|
301
|
+
function withContrast(color, background, min = MIN_CONTRAST) {
|
|
302
|
+
if (contrastRatio(color, background) >= min) {
|
|
303
|
+
return color;
|
|
304
|
+
}
|
|
305
|
+
const target = luminance(background) < 0.5 ? [255, 255, 255] : [0, 0, 0];
|
|
306
|
+
for (let step = 1; step <= 20; step++) {
|
|
307
|
+
const moved = mix(color, target, step / 20);
|
|
308
|
+
if (contrastRatio(moved, background) >= min) {
|
|
309
|
+
return moved;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
return target;
|
|
313
|
+
}
|
|
314
|
+
|
|
199
315
|
module.exports = {
|
|
200
316
|
CSI,
|
|
201
317
|
RESET,
|
|
@@ -221,4 +337,15 @@ module.exports = {
|
|
|
221
337
|
shade,
|
|
222
338
|
mix,
|
|
223
339
|
parseColor,
|
|
340
|
+
luminance,
|
|
341
|
+
contrastRatio,
|
|
342
|
+
toOklab,
|
|
343
|
+
fromOklab,
|
|
344
|
+
MAX_GRADIENT_STOPS,
|
|
345
|
+
parseGradientStops,
|
|
346
|
+
gradientAt,
|
|
347
|
+
gradientColors,
|
|
348
|
+
MIN_CONTRAST,
|
|
349
|
+
readableText,
|
|
350
|
+
withContrast,
|
|
224
351
|
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { defaultConfig, canonicalThemeId, LAYOUTS } = require("./shared");
|
|
4
|
+
const { THEMES, normalizeGradient } = require("./themes");
|
|
5
|
+
const { VALID_WIDGET_IDS } = require("./config");
|
|
6
|
+
const { COLOR_MODES } = require("./colors");
|
|
7
|
+
const { isMeterStyle } = require("./widget-style");
|
|
8
|
+
|
|
9
|
+
const KNOWN_KEYS = new Set([
|
|
10
|
+
...Object.keys(defaultConfig()),
|
|
11
|
+
"customTheme",
|
|
12
|
+
"gradient",
|
|
13
|
+
"meterStyle",
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
// Control characters shown as \uXXXX: the text is printed, never interpreted
|
|
17
|
+
// by the terminal or the browser.
|
|
18
|
+
function visible(text) {
|
|
19
|
+
return String(text).replace(
|
|
20
|
+
/[\x00-\x1f\x7f-\x9f\u2028\u2029\u202A-\u202E\u2066-\u2069]/g,
|
|
21
|
+
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Settings the raw config has that the bar can't use, so they don't come over
|
|
26
|
+
// on import or in the web configurator.
|
|
27
|
+
function droppedSettings(raw) {
|
|
28
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
29
|
+
return ["config is not an object"];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const dropped = [];
|
|
33
|
+
for (const key of Object.keys(raw)) {
|
|
34
|
+
if (!KNOWN_KEYS.has(key)) {
|
|
35
|
+
dropped.push(`unknown setting "${visible(key)}"`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (Array.isArray(raw.widgets)) {
|
|
39
|
+
for (const widget of raw.widgets) {
|
|
40
|
+
if (!widget || typeof widget !== "object" || !VALID_WIDGET_IDS.has(widget.id)) {
|
|
41
|
+
dropped.push(`unknown widget ${visible(JSON.stringify(widget?.id ?? widget) ?? "")}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} else if (raw.widgets !== undefined) {
|
|
45
|
+
dropped.push("widgets (not a list)");
|
|
46
|
+
}
|
|
47
|
+
if (raw.theme !== undefined && !THEMES[canonicalThemeId(raw.theme)]) {
|
|
48
|
+
dropped.push(`unknown theme ${visible(JSON.stringify(raw.theme))}`);
|
|
49
|
+
}
|
|
50
|
+
if (raw.layout !== undefined && !LAYOUTS[raw.layout]) {
|
|
51
|
+
dropped.push(`unknown layout ${visible(JSON.stringify(raw.layout))}`);
|
|
52
|
+
}
|
|
53
|
+
if (raw.color !== undefined && !COLOR_MODES.includes(raw.color)) {
|
|
54
|
+
dropped.push(`unknown color mode ${visible(JSON.stringify(raw.color))}`);
|
|
55
|
+
}
|
|
56
|
+
if (
|
|
57
|
+
raw.gradient !== undefined &&
|
|
58
|
+
raw.gradient !== null &&
|
|
59
|
+
raw.gradient !== "off" &&
|
|
60
|
+
!normalizeGradient(raw.gradient)
|
|
61
|
+
) {
|
|
62
|
+
dropped.push(`invalid gradient ${visible(JSON.stringify(raw.gradient))}`);
|
|
63
|
+
}
|
|
64
|
+
if (raw.meterStyle !== undefined && !isMeterStyle(raw.meterStyle)) {
|
|
65
|
+
dropped.push(`unknown meter style ${visible(JSON.stringify(raw.meterStyle))}`);
|
|
66
|
+
}
|
|
67
|
+
return dropped;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
KNOWN_KEYS,
|
|
72
|
+
visible,
|
|
73
|
+
droppedSettings,
|
|
74
|
+
};
|
package/src/lib/config.js
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
5
|
const { resolveColorModeId } = require("./colors");
|
|
6
|
-
const { resolveThemeId } = require("./themes");
|
|
6
|
+
const { resolveThemeId, normalizeGradient } = require("./themes");
|
|
7
7
|
const { stripControl, normalizeClock } = require("./formatters");
|
|
8
8
|
const { normalizePowerline } = require("./powerline");
|
|
9
9
|
const { writeFileAtomic, PRIVATE_FILE_MODE } = require("./paths");
|
|
10
|
+
const { isMeterStyle } = require("./widget-style");
|
|
10
11
|
|
|
11
12
|
// Display text in widget configs is printed as-is, so strip terminal control
|
|
12
13
|
// from it. cmd/command are left alone: they are executed, not printed.
|
|
@@ -39,6 +40,11 @@ const VALID_WIDGET_IDS = new Set([
|
|
|
39
40
|
"cwd",
|
|
40
41
|
"project",
|
|
41
42
|
"git",
|
|
43
|
+
"gitChanges",
|
|
44
|
+
"gitFiles",
|
|
45
|
+
"gitSha",
|
|
46
|
+
"gitStash",
|
|
47
|
+
"gitWorktree",
|
|
42
48
|
"plan",
|
|
43
49
|
"agentState",
|
|
44
50
|
"artifacts",
|
|
@@ -52,6 +58,8 @@ const VALID_WIDGET_IDS = new Set([
|
|
|
52
58
|
"cacheCreate",
|
|
53
59
|
"cacheHit",
|
|
54
60
|
"totalTokens",
|
|
61
|
+
"tokensToday",
|
|
62
|
+
"tokensWeek",
|
|
55
63
|
"exec",
|
|
56
64
|
"customText",
|
|
57
65
|
"separator",
|
|
@@ -175,6 +183,17 @@ function mergeConfig(raw) {
|
|
|
175
183
|
result.customTheme = raw.customTheme;
|
|
176
184
|
}
|
|
177
185
|
|
|
186
|
+
// An unknown meter style is left out: each layout then draws its own.
|
|
187
|
+
if (isMeterStyle(raw?.meterStyle)) {
|
|
188
|
+
result.meterStyle = raw.meterStyle;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Only a valid gradient is kept; anything else renders the plain theme.
|
|
192
|
+
const gradient = normalizeGradient(raw?.gradient);
|
|
193
|
+
if (gradient) {
|
|
194
|
+
result.gradient = gradient;
|
|
195
|
+
}
|
|
196
|
+
|
|
178
197
|
return result;
|
|
179
198
|
}
|
|
180
199
|
|
package/src/lib/exec.js
CHANGED
|
@@ -5,7 +5,8 @@ const path = require("path");
|
|
|
5
5
|
const os = require("os");
|
|
6
6
|
const crypto = require("crypto");
|
|
7
7
|
const { execSync } = require("child_process");
|
|
8
|
-
const { stripControl } = require("./formatters");
|
|
8
|
+
const { stripControl, sanitizeDeep } = require("./formatters");
|
|
9
|
+
const { RESET, getActiveDepth } = require("./colors");
|
|
9
10
|
const { logWarning } = require("./log");
|
|
10
11
|
const { PRIVATE_FILE_MODE } = require("./paths");
|
|
11
12
|
|
|
@@ -92,16 +93,123 @@ function cacheKey(cmd, cwd = "") {
|
|
|
92
93
|
.slice(0, 16);
|
|
93
94
|
}
|
|
94
95
|
|
|
96
|
+
// The only escape sequence "colors": true keeps: SGR color and style. Colon
|
|
97
|
+
// forms (38:2::r:g:b) are dropped with the rest, since not every terminal reads them.
|
|
98
|
+
const SGR_SPLIT = /(\x1b\[[0-9;]*m)/;
|
|
99
|
+
const SGR_ALL = /\x1b\[([0-9;]*)m/g;
|
|
100
|
+
|
|
95
101
|
// Command output is untrusted text: strip terminal control and cap length.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
102
|
+
// With colors, SGR sequences survive and the visible text alone is capped;
|
|
103
|
+
// the output then ends with a reset so its colors can't leak past it.
|
|
104
|
+
function sanitizeExecOutput(text, { colors = false } = {}) {
|
|
105
|
+
if (!colors) {
|
|
106
|
+
const clean = stripControl(text).trim();
|
|
107
|
+
const chars = Array.from(clean);
|
|
108
|
+
|
|
109
|
+
if (chars.length <= MAX_OUTPUT_CHARS) {
|
|
110
|
+
return clean;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return chars.slice(0, MAX_OUTPUT_CHARS - 1).join("") + "…";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const parts = String(text ?? "")
|
|
117
|
+
.split(SGR_SPLIT)
|
|
118
|
+
.map((part, i) => (i % 2 ? part : stripControl(part)));
|
|
119
|
+
const visible = parts.filter((_, i) => i % 2 === 0).join("");
|
|
120
|
+
const lead = visible.length - visible.trimStart().length;
|
|
121
|
+
const chars = Array.from(visible.trim());
|
|
122
|
+
const cut = chars.length > MAX_OUTPUT_CHARS;
|
|
123
|
+
const budget = cut ? MAX_OUTPUT_CHARS - 1 : chars.length;
|
|
124
|
+
let skip = lead;
|
|
125
|
+
let used = 0;
|
|
126
|
+
let out = "";
|
|
127
|
+
let styled = false;
|
|
128
|
+
|
|
129
|
+
for (let i = 0; i < parts.length && used < budget; i++) {
|
|
130
|
+
if (i % 2) {
|
|
131
|
+
out += parts[i];
|
|
132
|
+
styled = true;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
for (const char of Array.from(parts[i])) {
|
|
136
|
+
if (skip > 0) {
|
|
137
|
+
skip -= char.length;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (used === budget) {
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
out += char;
|
|
144
|
+
used++;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
99
147
|
|
|
100
|
-
|
|
101
|
-
|
|
148
|
+
return out + (cut ? "…" : "") + (styled ? RESET : "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// After a reset in colored output, the widget's own colors come back (the
|
|
152
|
+
// pill's background and text in Pills), so the rest of the widget isn't left
|
|
153
|
+
// in the terminal's default colors.
|
|
154
|
+
function restoreAfterReset(text, restore) {
|
|
155
|
+
if (!restore) {
|
|
156
|
+
return text;
|
|
102
157
|
}
|
|
158
|
+
return String(text).replace(SGR_ALL, (seq, body) => {
|
|
159
|
+
const params = body === "" ? [0] : body.split(";").map((p) => (p === "" ? 0 : Number(p)));
|
|
160
|
+
let lastReset = -1;
|
|
161
|
+
for (let i = 0; i < params.length; i++) {
|
|
162
|
+
// 38/48/58 take a color as their next parameters; a 0 there isn't a reset.
|
|
163
|
+
if (params[i] === 38 || params[i] === 48 || params[i] === 58) {
|
|
164
|
+
i += params[i + 1] === 5 ? 2 : params[i + 1] === 2 ? 4 : 0;
|
|
165
|
+
} else if (params[i] === 0) {
|
|
166
|
+
lastReset = i;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (lastReset < 0) {
|
|
170
|
+
return seq;
|
|
171
|
+
}
|
|
172
|
+
const rest = params.slice(lastReset + 1);
|
|
173
|
+
return `${RESET}${restore}${rest.length ? `\x1b[${rest.join(";")}m` : ""}`;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Never handed to a command: agy-powerline doesn't read the account email or
|
|
178
|
+
// the session and conversation IDs, so it doesn't pass them on either.
|
|
179
|
+
const PRIVATE_KEYS = new Set(["email", "session_id", "conversation_id"]);
|
|
180
|
+
|
|
181
|
+
function withoutPrivateKeys(value, depth = 0) {
|
|
182
|
+
if (value === null || typeof value !== "object") {
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
if (depth > 8) {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
if (Array.isArray(value)) {
|
|
189
|
+
return value.map((item) => withoutPrivateKeys(item, depth + 1));
|
|
190
|
+
}
|
|
191
|
+
const out = {};
|
|
192
|
+
for (const [key, item] of Object.entries(value)) {
|
|
193
|
+
if (!PRIVATE_KEYS.has(key)) {
|
|
194
|
+
out[key] = withoutPrivateKeys(item, depth + 1);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The session JSON an exec widget with "payload": true reads on stdin.
|
|
201
|
+
function execPayloadJson(payload) {
|
|
202
|
+
try {
|
|
203
|
+
return JSON.stringify(sanitizeDeep(withoutPrivateKeys(payload ?? {}))) ?? "{}";
|
|
204
|
+
} catch {
|
|
205
|
+
return "{}";
|
|
206
|
+
}
|
|
207
|
+
}
|
|
103
208
|
|
|
104
|
-
|
|
209
|
+
// Widgets that share a command but not its options must not share its output.
|
|
210
|
+
function widgetCacheKey(widget, cwd) {
|
|
211
|
+
const variant = `${widget.payload === true ? "\0payload" : ""}${widget.colors === true ? "\0colors" : ""}`;
|
|
212
|
+
return cacheKey(`${String(widget.cmd).trim()}${variant}`, cwd);
|
|
105
213
|
}
|
|
106
214
|
|
|
107
215
|
// session.execMode: "run" executes the command, "blocked" marks a config the
|
|
@@ -115,11 +223,12 @@ function execWidgetOutput(widget, session = {}) {
|
|
|
115
223
|
// Fitting the bar to the terminal can draw a row several times; a command
|
|
116
224
|
// still runs at most once per refresh, even with "interval": 0.
|
|
117
225
|
const memo = session.execMemo;
|
|
118
|
-
const key =
|
|
226
|
+
const key = widgetCacheKey(widget, session.cwd || process.cwd());
|
|
119
227
|
if (memo?.has(key)) {
|
|
120
228
|
return memo.get(key);
|
|
121
229
|
}
|
|
122
|
-
const
|
|
230
|
+
const colors = widget.colors === true && getActiveDepth() !== "none";
|
|
231
|
+
const output = sanitizeExecOutput(runExecWidget(widget, session), { colors });
|
|
123
232
|
memo?.set(key, output);
|
|
124
233
|
return output;
|
|
125
234
|
}
|
|
@@ -134,14 +243,15 @@ function runExecWidget(widget, session = {}) {
|
|
|
134
243
|
}
|
|
135
244
|
|
|
136
245
|
const cwd = session.cwd || process.cwd();
|
|
137
|
-
const key =
|
|
246
|
+
const key = widgetCacheKey(widget, cwd);
|
|
138
247
|
const now = Date.now();
|
|
248
|
+
const input = widget.payload === true ? `${session.execPayload || "{}"}\n` : undefined;
|
|
139
249
|
|
|
140
250
|
let intervalMs = DEFAULT_INTERVAL_SEC * 1000;
|
|
141
251
|
if (widget.interval != null) {
|
|
142
252
|
const parsed = Number(widget.interval);
|
|
143
253
|
if (Number.isFinite(parsed) && parsed >= 0) {
|
|
144
|
-
intervalMs = parsed
|
|
254
|
+
intervalMs = parsed * 1000;
|
|
145
255
|
}
|
|
146
256
|
}
|
|
147
257
|
|
|
@@ -169,24 +279,30 @@ function runExecWidget(widget, session = {}) {
|
|
|
169
279
|
|
|
170
280
|
// 3. Cache expired or missing: execute command with timeout
|
|
171
281
|
let output = diskHit?.output || memHit?.output || "";
|
|
282
|
+
const firstLine = (stdout) =>
|
|
283
|
+
String(stdout || "")
|
|
284
|
+
.trim()
|
|
285
|
+
.split(/\r?\n/)[0]
|
|
286
|
+
?.trim() || "";
|
|
172
287
|
try {
|
|
173
288
|
const stdout = execSync(cmd, {
|
|
174
289
|
cwd,
|
|
175
290
|
timeout: timeoutMs,
|
|
176
291
|
encoding: "utf8",
|
|
177
|
-
|
|
292
|
+
input,
|
|
293
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "ignore"],
|
|
178
294
|
windowsHide: true,
|
|
179
295
|
});
|
|
180
|
-
output =
|
|
181
|
-
String(stdout || "")
|
|
182
|
-
.trim()
|
|
183
|
-
.split(/\r?\n/)[0]
|
|
184
|
-
?.trim() || "";
|
|
296
|
+
output = firstLine(stdout);
|
|
185
297
|
} catch (err) {
|
|
186
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
298
|
+
// A command that exits without reading the whole payload closes the pipe
|
|
299
|
+
// under the write; it still succeeded, so its output counts. Otherwise the
|
|
300
|
+
// previous output stays. A non-zero exit is normal for many commands (grep
|
|
301
|
+
// with no match), so only timeouts and missing commands are logged, and
|
|
302
|
+
// never the command itself.
|
|
303
|
+
if (input !== undefined && err.status === 0 && !err.signal) {
|
|
304
|
+
output = firstLine(err.stdout);
|
|
305
|
+
} else if (err.code === "ETIMEDOUT" || err.signal === "SIGTERM") {
|
|
190
306
|
logWarning("EXEC_TIMEOUT", { timeoutMs });
|
|
191
307
|
} else if (err.status === 127 || err.code === "ENOENT") {
|
|
192
308
|
logWarning("EXEC_COMMAND_NOT_FOUND");
|
|
@@ -223,6 +339,9 @@ module.exports = {
|
|
|
223
339
|
runExecWidget,
|
|
224
340
|
execWidgetOutput,
|
|
225
341
|
sanitizeExecOutput,
|
|
342
|
+
restoreAfterReset,
|
|
343
|
+
execPayloadJson,
|
|
344
|
+
widgetCacheKey,
|
|
226
345
|
cacheKey,
|
|
227
346
|
clearExecCache,
|
|
228
347
|
getCacheFilePath,
|
package/src/lib/fit.js
CHANGED
|
@@ -6,12 +6,16 @@ const { charWidth, visibleWidth } = require("./formatters");
|
|
|
6
6
|
// first entry first. Ids not listed (custom ones) go before all of them.
|
|
7
7
|
const DROP_ORDER = [
|
|
8
8
|
"customText",
|
|
9
|
+
"gitStash",
|
|
10
|
+
"gitWorktree",
|
|
9
11
|
"title",
|
|
10
12
|
"version",
|
|
11
13
|
"cacheCreate",
|
|
12
14
|
"cacheRead",
|
|
13
15
|
"totalTokens",
|
|
14
16
|
"cacheHit",
|
|
17
|
+
"tokensToday",
|
|
18
|
+
"tokensWeek",
|
|
15
19
|
"artifacts",
|
|
16
20
|
"tasks",
|
|
17
21
|
"plan",
|
|
@@ -28,6 +32,9 @@ const DROP_ORDER = [
|
|
|
28
32
|
"weeklyReset",
|
|
29
33
|
"fiveHourReset",
|
|
30
34
|
"contextTokens",
|
|
35
|
+
"gitSha",
|
|
36
|
+
"gitFiles",
|
|
37
|
+
"gitChanges",
|
|
31
38
|
"git",
|
|
32
39
|
"sandbox",
|
|
33
40
|
"weeklyPct",
|
package/src/lib/formatters.js
CHANGED
|
@@ -271,6 +271,24 @@ function formatTokenSplit(input, output, ascii = false) {
|
|
|
271
271
|
: `${formatTokens(input)}↑ ${formatTokens(output)}↓`;
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
// The usage widgets' text: "1.2M", or "1.0M↑ 245.3K↓" with "split", plus the
|
|
275
|
+
// share of a "budget" when one is set ("1.2M (60%)"). null with no tokens.
|
|
276
|
+
function formatUsage(tokens, widget = {}, ascii = false) {
|
|
277
|
+
const input = Number(tokens?.input) || 0;
|
|
278
|
+
const output = Number(tokens?.output) || 0;
|
|
279
|
+
const total = input + output;
|
|
280
|
+
if (!(total > 0)) {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
const value = widget?.split ? formatTokenSplit(input, output, ascii) : formatTokens(total);
|
|
284
|
+
const budget = widget?.budget;
|
|
285
|
+
const pct =
|
|
286
|
+
typeof budget === "number" && Number.isFinite(budget) && budget > 0
|
|
287
|
+
? (total * 100) / budget
|
|
288
|
+
: null;
|
|
289
|
+
return { text: pct === null ? value : `${value} (${Math.round(pct)}%)`, pct };
|
|
290
|
+
}
|
|
291
|
+
|
|
274
292
|
// "used/window", or just the used tokens while the window size is unknown.
|
|
275
293
|
function formatContextTokens(used, size) {
|
|
276
294
|
return size > 0 ? `${formatTokens(used)}/${formatTokens(size)}` : formatTokens(used);
|
|
@@ -460,6 +478,31 @@ function visibleWidth(text) {
|
|
|
460
478
|
return width;
|
|
461
479
|
}
|
|
462
480
|
|
|
481
|
+
// "+128 −34" as parts, so Lines can color each; zero counts are left out.
|
|
482
|
+
function gitChangeParts(changes, ascii = false) {
|
|
483
|
+
const parts = [];
|
|
484
|
+
if (changes?.added > 0) {
|
|
485
|
+
parts.push({ kind: "added", text: `+${changes.added}` });
|
|
486
|
+
}
|
|
487
|
+
if (changes?.removed > 0) {
|
|
488
|
+
parts.push({ kind: "removed", text: `${ascii ? "-" : "−"}${changes.removed}` });
|
|
489
|
+
}
|
|
490
|
+
return parts;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const GIT_FILE_MARKS = {
|
|
494
|
+
unicode: { staged: "+", modified: "●", untracked: "?", conflicts: "≠" },
|
|
495
|
+
ascii: { staged: "S", modified: "M", untracked: "?", conflicts: "!" },
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
// "+2 ●3 ?1 ≠1" as parts: staged, modified, untracked, in conflict.
|
|
499
|
+
function gitFileParts(files, ascii = false) {
|
|
500
|
+
const marks = GIT_FILE_MARKS[ascii ? "ascii" : "unicode"];
|
|
501
|
+
return Object.keys(marks)
|
|
502
|
+
.filter((kind) => files?.[kind] > 0)
|
|
503
|
+
.map((kind) => ({ kind, text: `${marks[kind]}${files[kind]}` }));
|
|
504
|
+
}
|
|
505
|
+
|
|
463
506
|
function formatGitSync(ahead, behind, ascii = false) {
|
|
464
507
|
const up = ascii ? "^" : "⇡";
|
|
465
508
|
const down = ascii ? "v" : "⇣";
|
|
@@ -477,6 +520,26 @@ function formatGitSync(ahead, behind, ascii = false) {
|
|
|
477
520
|
return "";
|
|
478
521
|
}
|
|
479
522
|
|
|
523
|
+
const GIT_OPERATION_LABELS = {
|
|
524
|
+
rebase: "REBASE",
|
|
525
|
+
am: "AM",
|
|
526
|
+
merge: "MERGING",
|
|
527
|
+
"cherry-pick": "CHERRY-PICKING",
|
|
528
|
+
revert: "REVERTING",
|
|
529
|
+
bisect: "BISECTING",
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
// "REBASE 2/5", "MERGING", or "" with no operation. Plain ASCII in both modes.
|
|
533
|
+
function formatGitOperation(op) {
|
|
534
|
+
const name = GIT_OPERATION_LABELS[op?.name];
|
|
535
|
+
if (!name) {
|
|
536
|
+
return "";
|
|
537
|
+
}
|
|
538
|
+
return Number.isInteger(op.step) && Number.isInteger(op.total)
|
|
539
|
+
? `${name} ${op.step}/${op.total}`
|
|
540
|
+
: name;
|
|
541
|
+
}
|
|
542
|
+
|
|
480
543
|
// "2 subagents, 1 task", or "" when nothing runs.
|
|
481
544
|
function formatTasks(subagents, tasks) {
|
|
482
545
|
const part = (n, word) => (n > 0 ? `${n} ${word}${n === 1 ? "" : "s"}` : "");
|
|
@@ -585,7 +648,11 @@ module.exports = {
|
|
|
585
648
|
formatContextTokens,
|
|
586
649
|
shortModelName,
|
|
587
650
|
formatTokenSplit,
|
|
651
|
+
formatUsage,
|
|
588
652
|
formatGitSync,
|
|
653
|
+
formatGitOperation,
|
|
654
|
+
gitChangeParts,
|
|
655
|
+
gitFileParts,
|
|
589
656
|
formatTasks,
|
|
590
657
|
formatMode,
|
|
591
658
|
clipText,
|