pi-tui-status-beautifier 1.2.1 → 1.3.1

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.
@@ -11,9 +11,41 @@ const COMMON_NAMES: Record<string, string> = {
11
11
  "subagent-slash-text": "subagent",
12
12
  "pi-wechat-assistant": "wechat",
13
13
  "wechat-assistant": "wechat",
14
+ "pi-control-chrome": "chrome",
15
+ chrome: "chrome",
16
+ toolflow: "toolflow",
14
17
  };
15
18
 
16
- const VALID_STYLES = ["minimal", "glass", "glow", "off"] as const;
19
+ export interface CustomStyleConfig {
20
+ prefix?: string;
21
+ suffix?: string;
22
+ separator?: string;
23
+ brackets?: [string, string];
24
+ inverse?: boolean;
25
+ glyphs?: {
26
+ success?: string;
27
+ warning?: string;
28
+ error?: string;
29
+ accent?: string;
30
+ };
31
+ }
32
+
33
+ const BUILTIN_PRESETS: Record<string, CustomStyleConfig> = {
34
+ minimal: {
35
+ separator: " ❯ ",
36
+ },
37
+ glass: {
38
+ prefix: "▕ ",
39
+ suffix: " ▏",
40
+ separator: " ",
41
+ },
42
+ glow: {
43
+ brackets: ["[", "]"],
44
+ inverse: true,
45
+ },
46
+ };
47
+
48
+ const VALID_STYLES = ["minimal", "glass", "glow", "custom", "off"] as const;
17
49
 
18
50
  // Store original statuses to allow dynamic redrawing on configuration changes.
19
51
  const originalStatuses = new Map<string, string | undefined>();
@@ -27,38 +59,83 @@ const ANSI_STRIP_REGEX =
27
59
 
28
60
  // Hoisted regex patterns to prevent instantiation and GC overhead in the render hot-path
29
61
  const STATUS_PATTERN =
30
- /[🟢🔴🟡⚪⏸✅❌✓✗?✔✖☑☐◆📋⌬]|[0-9]+\/[0-9]+|active|online|offline|running|paused|error|success|connected|disconnected/i;
62
+ /[🟢🔴🟡⚪⏸✅❌✓✗?✔✖☑☐◆📋⌬★✦◇»«‹›⦗⦘]|[0-9]+\/[0-9]+|active|online|offline|running|paused|error|success|connected|disconnected|ready|idle|ok|done|failed|waiting|stopped|listening|cleanup|starting/i;
31
63
  const NAME_SUFFIX_PATTERN = /-(extension|plugin|assistant|adapter|slash-text|slash|text|widget)$/gi;
32
64
  const FRACTION_PATTERN = /(\d+\/\d+|\d+%\s*)/;
33
65
  const PARENTHESES_PATTERN = /\((\d+)\)/;
34
66
  const BRACKET_PATTERN = /\[(\d+)\]/;
35
67
  const NUMBER_PATTERN = /\b(\d+)\b/;
36
68
 
37
- // Load settings path
38
- const home = process.env.HOME || process.env.USERPROFILE || "";
39
- const settingsPath = path.join(home, ".pi/agent/settings.json");
40
-
41
69
  let currentStyle = "minimal";
70
+ let customStyleConfig: CustomStyleConfig | undefined;
42
71
 
43
- try {
44
- if (fs.existsSync(settingsPath)) {
45
- const data = fs.readFileSync(settingsPath, "utf-8");
46
- const config = JSON.parse(data);
47
- if (config.beautifier && typeof config.beautifier.style === "string") {
48
- const saved = config.beautifier.style;
49
- currentStyle = (VALID_STYLES as readonly string[]).includes(saved) ? saved : "minimal";
72
+ /**
73
+ * Resolves configuration hierarchically:
74
+ * Project-level `./.pi/settings.json` takes priority over global `~/.pi/agent/settings.json`
75
+ */
76
+ export function loadConfig(cwd: string = process.cwd()): {
77
+ style: string;
78
+ custom?: CustomStyleConfig;
79
+ targetSettingsPath: string;
80
+ } {
81
+ const home = process.env.HOME || process.env.USERPROFILE || "";
82
+ const globalPath = path.join(home, ".pi/agent/settings.json");
83
+ const localPath = path.join(cwd, ".pi/settings.json");
84
+
85
+ let resolvedStyle = "minimal";
86
+ let resolvedCustom: CustomStyleConfig | undefined;
87
+ let hasLocalConfig = false;
88
+
89
+ // 1. Read global settings
90
+ try {
91
+ if (fs.existsSync(globalPath)) {
92
+ const data = JSON.parse(fs.readFileSync(globalPath, "utf-8"));
93
+ if (data.beautifier) {
94
+ if (typeof data.beautifier.style === "string") {
95
+ const s = data.beautifier.style;
96
+ if ((VALID_STYLES as readonly string[]).includes(s)) resolvedStyle = s;
97
+ }
98
+ if (data.beautifier.custom && typeof data.beautifier.custom === "object") {
99
+ resolvedCustom = data.beautifier.custom;
100
+ }
101
+ }
50
102
  }
51
- }
52
- } catch (_e) {
53
- // Graceful fallback to default in case of JSON parse or read errors
103
+ } catch (_) {}
104
+
105
+ // 2. Project-level override (inherits and overrides global)
106
+ try {
107
+ if (fs.existsSync(localPath)) {
108
+ const data = JSON.parse(fs.readFileSync(localPath, "utf-8"));
109
+ if (data.beautifier) {
110
+ hasLocalConfig = true;
111
+ if (typeof data.beautifier.style === "string") {
112
+ const s = data.beautifier.style;
113
+ if ((VALID_STYLES as readonly string[]).includes(s)) resolvedStyle = s;
114
+ }
115
+ if (data.beautifier.custom && typeof data.beautifier.custom === "object") {
116
+ resolvedCustom = { ...(resolvedCustom || {}), ...data.beautifier.custom };
117
+ }
118
+ }
119
+ }
120
+ } catch (_) {}
121
+
122
+ currentStyle = resolvedStyle;
123
+ customStyleConfig = resolvedCustom;
124
+
125
+ // Target path for atomic write: local project if exists or in project directory, otherwise global
126
+ const targetSettingsPath = hasLocalConfig || fs.existsSync(localPath) ? localPath : globalPath;
127
+ return { style: resolvedStyle, custom: resolvedCustom, targetSettingsPath };
54
128
  }
55
129
 
130
+ loadConfig();
131
+
56
132
  // Clean status text and map state and details
57
133
  export function beautifyStatus(
58
134
  key: string,
59
135
  originalVal: string | undefined,
60
136
  theme: any,
61
- style: string
137
+ style: string,
138
+ customCfg?: CustomStyleConfig
62
139
  ): string | undefined {
63
140
  if (originalVal === undefined) return undefined;
64
141
  if (style === "off") return originalVal;
@@ -83,6 +160,9 @@ export function beautifyStatus(
83
160
  name = name.slice(lastSlash + 1);
84
161
  }
85
162
  name = name.replace(NAME_SUFFIX_PATTERN, "").toLowerCase();
163
+ if (COMMON_NAMES[name]) {
164
+ name = COMMON_NAMES[name];
165
+ }
86
166
  }
87
167
  const visualName = padToVisualWidth(sliceToVisualWidth(name, 10), 10);
88
168
 
@@ -113,7 +193,8 @@ export function beautifyStatus(
113
193
  valLower.includes("✗") ||
114
194
  valLower.includes("offline") ||
115
195
  valLower.includes("error") ||
116
- valLower.includes("failed")
196
+ valLower.includes("failed") ||
197
+ valLower.includes("stopped")
117
198
  ) {
118
199
  state = "error";
119
200
  } else if (
@@ -123,7 +204,9 @@ export function beautifyStatus(
123
204
  valLower.includes("warning") ||
124
205
  valLower.includes("pause") ||
125
206
  valLower.includes("planning") ||
126
- valLower.includes("plan")
207
+ valLower.includes("plan") ||
208
+ valLower.includes("waiting") ||
209
+ valLower.includes("cleanup")
127
210
  ) {
128
211
  state = "warning";
129
212
  } else if (
@@ -131,45 +214,64 @@ export function beautifyStatus(
131
214
  valLower.includes("✅") ||
132
215
  valLower.includes("✓") ||
133
216
  valLower.includes("online") ||
134
- valLower.includes("success")
217
+ valLower.includes("success") ||
218
+ valLower.includes("ready") ||
219
+ valLower.includes("idle") ||
220
+ valLower.includes("ok") ||
221
+ valLower.includes("done")
135
222
  ) {
136
223
  state = "success";
137
224
  } else {
138
225
  state = name === "plan" ? "accent" : "success";
139
226
  }
140
227
 
141
- // 4. Map state to target glyph shapes
142
- let char = "●";
143
- if (state === "warning") char = "◌";
144
- else if (state === "error") char = "▲";
228
+ // 4. Determine state glyph & indicator
229
+ const preset =
230
+ style === "custom"
231
+ ? (customCfg || customStyleConfig || {})
232
+ : (BUILTIN_PRESETS[style] || BUILTIN_PRESETS.minimal);
233
+
234
+ const glyphMap = preset.glyphs || {};
235
+ let glyph =
236
+ glyphMap[state] ||
237
+ (state === "warning" ? "◌" : state === "error" ? "▲" : state === "accent" ? "◆" : "●");
145
238
 
146
239
  const hasThemeFg = theme && typeof theme.fg === "function";
147
- const coloredIndicator = hasThemeFg ? theme.fg(state, char) : char;
148
-
149
- // 5. Render layout presets (3 curated baseline layouts: minimal, glass, glow)
150
- switch (style) {
151
- case "glass": {
152
- const prefix = hasThemeFg ? theme.fg("dim", "▕ ") : "▕ ";
153
- const suffix = hasThemeFg ? theme.fg("dim", " ▏") : " ▏";
154
- const dimmedName = hasThemeFg ? theme.fg("muted", visualName) : visualName;
155
- const detailStr = details ? ` (${details})` : "";
156
- return `${prefix}${dimmedName} ${coloredIndicator}${detailStr}${suffix}`;
157
- }
158
- case "glow": {
159
- const label = ` ${char}${details ? ` ${details}` : ""} `;
160
- const hasInverse = theme && typeof theme.inverse === "function";
161
- const coloredBadge = hasInverse && hasThemeFg ? theme.inverse(theme.fg(state, label)) : `[${label}]`;
162
- const dimmedName = hasThemeFg ? theme.fg("muted", visualName) : visualName;
163
- return `${dimmedName} ${coloredBadge}`;
164
- }
165
- case "minimal":
166
- default: {
167
- const separator = hasThemeFg ? theme.fg("dim", " ❯ ") : " ❯ ";
168
- const dimmedName = hasThemeFg ? theme.fg("muted", visualName) : visualName;
169
- const detailStr = details ? ` (${details})` : "";
170
- return `${dimmedName}${separator}${coloredIndicator}${detailStr}`;
171
- }
240
+ const coloredGlyph = hasThemeFg ? theme.fg(state, glyph) : glyph;
241
+ const dimmedName = hasThemeFg ? theme.fg("muted", visualName) : visualName;
242
+
243
+ const openBracket = preset.brackets?.[0] ?? "";
244
+ const closeBracket = preset.brackets?.[1] ?? "";
245
+ const prefix = preset.prefix ?? "";
246
+ const suffix = preset.suffix ?? "";
247
+ const sep = preset.separator ?? (preset.brackets ? " " : " ❯ ");
248
+
249
+ // Inverse pill badge (glow preset)
250
+ if (preset.inverse && theme && typeof theme.inverse === "function") {
251
+ const pillContent = ` ${glyph}${details ? ` ${details}` : ""} `;
252
+ const coloredBadge = hasThemeFg ? theme.inverse(theme.fg(state, pillContent)) : `[${pillContent}]`;
253
+ return `${prefix}${dimmedName} ${coloredBadge}${suffix}`;
254
+ }
255
+
256
+ let badge = coloredGlyph;
257
+ if (details) {
258
+ badge = `${coloredGlyph} ${details}`;
259
+ }
260
+ if (openBracket && closeBracket) {
261
+ const styledOpen = hasThemeFg ? theme.fg("dim", openBracket) : openBracket;
262
+ const styledClose = hasThemeFg ? theme.fg("dim", closeBracket) : closeBracket;
263
+ badge = `${styledOpen}${badge}${styledClose}`;
264
+ } else if (details && !preset.brackets) {
265
+ const detailStr = hasThemeFg ? theme.fg("dim", ` (${details})`) : ` (${details})`;
266
+ badge = `${coloredGlyph}${detailStr}`;
172
267
  }
268
+
269
+ const styledSep =
270
+ hasThemeFg && (sep.includes("│") || sep.includes("⁝") || sep.includes("❯") || sep.includes("»"))
271
+ ? theme.fg("dim", sep)
272
+ : sep;
273
+
274
+ return `${prefix}${dimmedName}${styledSep}${badge}${suffix}`;
173
275
  } catch (_error) {
174
276
  return originalVal;
175
277
  }
@@ -278,13 +380,111 @@ export function padToVisualWidth(str: string, targetWidth: number, padChar = " "
278
380
  return str + padChar.repeat(targetWidth - currentWidth);
279
381
  }
280
382
 
383
+ /**
384
+ * Full-Footer Renderer (Hooked via ctx.ui.setFooter)
385
+ * Formats working folder, Git branch, token metrics, and active model on Line 1,
386
+ * and formats all extension statuses on Line 2.
387
+ */
388
+ export function renderBeautifiedFooter(
389
+ width: number,
390
+ _tui: any,
391
+ theme: any,
392
+ footerData: any,
393
+ ctx: any,
394
+ style: string,
395
+ customCfg?: CustomStyleConfig
396
+ ): string[] {
397
+ const preset =
398
+ style === "custom"
399
+ ? (customCfg || customStyleConfig || {})
400
+ : (BUILTIN_PRESETS[style] || BUILTIN_PRESETS.minimal);
401
+
402
+ const hasThemeFg = theme && typeof theme.fg === "function";
403
+ const sep = preset.separator || (preset.brackets ? " " : " ❯ ");
404
+ const styledSep =
405
+ hasThemeFg && (sep.includes("│") || sep.includes("⁝") || sep.includes("❯") || sep.includes("»"))
406
+ ? theme.fg("dim", sep)
407
+ : sep;
408
+
409
+ // 1. Folder & Git branch
410
+ const branch = typeof footerData?.getGitBranch === "function" ? footerData.getGitBranch() : "";
411
+ const folder = path.basename(ctx?.cwd || process.cwd()) || "workspace";
412
+ const branchPart = branch ? `${folder} (${branch})` : folder;
413
+ const styledBranch = hasThemeFg ? theme.fg("dim", branchPart) : branchPart;
414
+
415
+ // 2. Model
416
+ const modelName = ctx?.model?.name || ctx?.model?.id || "";
417
+ const styledModel = modelName ? (hasThemeFg ? theme.fg("muted", modelName) : modelName) : "";
418
+
419
+ // 3. Token metrics
420
+ let input = 0, output = 0;
421
+ if (ctx?.sessionManager?.getBranch) {
422
+ try {
423
+ for (const e of ctx.sessionManager.getBranch()) {
424
+ if (e.type === "message" && e.message?.role === "assistant" && e.message?.usage) {
425
+ const u = e.message.usage;
426
+ input += u.input || 0;
427
+ output += u.output || 0;
428
+ }
429
+ }
430
+ } catch (_) {}
431
+ }
432
+ const fmt = (n: number) => (n < 1000 ? `${n}` : `${(n / 1000).toFixed(1)}k`);
433
+ const tokenStr = input > 0 || output > 0 ? `↑${fmt(input)} ↓${fmt(output)}` : "";
434
+ const styledTokens = tokenStr ? (hasThemeFg ? theme.fg("dim", tokenStr) : tokenStr) : "";
435
+
436
+ // Left & right parts of Line 1
437
+ const leftText = styledBranch;
438
+ const rightParts = [styledTokens, styledModel].filter(Boolean);
439
+ const rightText = rightParts.join(styledSep);
440
+
441
+ const leftVis = getStringWidth(leftText);
442
+ const rightVis = getStringWidth(rightText);
443
+ const padLen = Math.max(1, width - leftVis - rightVis);
444
+ const line1 = leftText + " ".repeat(padLen) + rightText;
445
+
446
+ const lines = [line1];
447
+
448
+ // Line 2: Extension status badges
449
+ const extStatuses: Array<[string, string]> = [];
450
+ if (footerData && typeof footerData.getExtensionStatuses === "function") {
451
+ try {
452
+ const map = footerData.getExtensionStatuses();
453
+ if (map && typeof map.entries === "function") {
454
+ for (const [k, v] of map.entries()) {
455
+ if (v) extStatuses.push([k, v]);
456
+ }
457
+ }
458
+ } catch (_) {}
459
+ }
460
+ if (extStatuses.length === 0) {
461
+ for (const [k, v] of originalStatuses.entries()) {
462
+ if (v) extStatuses.push([k, v]);
463
+ }
464
+ }
465
+
466
+ if (extStatuses.length > 0) {
467
+ const badges = extStatuses
468
+ .map(([k, v]) => beautifyStatus(k, v, theme, style, customCfg))
469
+ .filter(Boolean) as string[];
470
+ if (badges.length > 0) {
471
+ lines.push(badges.join(" "));
472
+ }
473
+ }
474
+
475
+ return lines;
476
+ }
477
+
281
478
  export default function (pi: ExtensionAPI) {
282
479
  pi.on("session_start", async (_event, ctx) => {
283
480
  if (!ctx.hasUI || !ctx.ui) return;
284
481
 
482
+ loadConfig(ctx.cwd || process.cwd());
483
+
285
484
  originalStatuses.clear();
286
485
  lastRenderedStatus.clear();
287
486
 
487
+ // 1. Hook setStatus for granular extension badge formatting
288
488
  const originalSetStatus = ctx.ui.setStatus;
289
489
  if (originalSetStatus && !(originalSetStatus as any).__beautifierHooked) {
290
490
  const wrapped = function (key: string, value: string | undefined) {
@@ -303,6 +503,25 @@ export default function (pi: ExtensionAPI) {
303
503
  (wrapped as any).__beautifierHooked = true;
304
504
  ctx.ui.setStatus = wrapped;
305
505
  }
506
+
507
+ // 2. Hook setFooter for full-footer beautification
508
+ if (typeof (ctx.ui as any).setFooter === "function") {
509
+ (ctx.ui as any).setFooter((tui: any, theme: any, footerData: any) => {
510
+ const unsub =
511
+ typeof footerData?.onBranchChange === "function"
512
+ ? footerData.onBranchChange(() => tui.requestRender())
513
+ : undefined;
514
+
515
+ return {
516
+ dispose: unsub,
517
+ invalidate() {},
518
+ render(width: number): string[] {
519
+ if (currentStyle === "off") return [];
520
+ return renderBeautifiedFooter(width, tui, theme, footerData, ctx, currentStyle, customStyleConfig);
521
+ },
522
+ };
523
+ });
524
+ }
306
525
  });
307
526
 
308
527
  // Dynamic status bar style controller registry
@@ -329,15 +548,27 @@ export default function (pi: ExtensionAPI) {
329
548
 
330
549
  if (styleKey === "custom") {
331
550
  const promptRecipe = [
332
- "You are acting as my Pi TUI Status Bar designer.",
333
- "1. Detect my primary language from our recent conversation history or environment locale.",
334
- "2. In that EXACT primary language, proactively ask me what visual aesthetic I want for my status bar, and propose 3 distinct creative ideas (e.g. Cyberpunk Dot-Matrix, Warm Minimalist, High-Contrast Pill).",
335
- "3. Wait for my preference or feedback, then inspect `tui-status-beautifier.ts` and apply the custom layout for me."
551
+ "You are acting as my Pi TUI Status Bar designer for `pi-tui-status-beautifier`.",
552
+ "1. In my primary language, call `ask_user_question` with ASCII visual preview mockups (e.g. Cyber Powerline ‹●›, Nordic Minimalist │ ◈, Capsule Pill [●], Tokyo Night » ✦) to consult my visual taste.",
553
+ "2. Once I select or describe my aesthetic, save the configuration directly into `./.pi/settings.json` (or `~/.pi/agent/settings.json`) under `beautifier`:",
554
+ " {",
555
+ ' "beautifier": {',
556
+ ' "style": "custom",',
557
+ ' "custom": {',
558
+ ' "separator": " » ",',
559
+ ' "brackets": ["«", "»"],',
560
+ ' "prefix": "✦ ",',
561
+ ' "suffix": "",',
562
+ ' "glyphs": { "success": "✦", "warning": "◇", "error": "✖", "accent": "✧" }',
563
+ " }",
564
+ " }",
565
+ " }",
566
+ "Do NOT crawl filesystem or edit extension source code files."
336
567
  ].join("\n");
337
568
 
338
569
  if (typeof (ctx.ui as any).setEditorText === "function") {
339
570
  (ctx.ui as any).setEditorText(promptRecipe);
340
- ctx.ui.notify("Press Enter to let your agent consult your style preference in your native language!", "info");
571
+ ctx.ui.notify("Press Enter to let your agent consult your style preference with visual previews!", "info");
341
572
  } else {
342
573
  ctx.ui.notify(promptRecipe, "info");
343
574
  }
@@ -349,23 +580,25 @@ export default function (pi: ExtensionAPI) {
349
580
  lastRenderedStatus.clear();
350
581
  ctx.ui.notify(`TUI status style changed to: ${styleKey}`, "info");
351
582
 
352
- for (const [key, val] of originalStatuses.entries()) {
353
- ctx.ui.setStatus(key, val);
354
- }
583
+ const { targetSettingsPath } = loadConfig(ctx.cwd || process.cwd());
355
584
 
356
585
  try {
357
- if (fs.existsSync(settingsPath)) {
358
- const data = fs.readFileSync(settingsPath, "utf-8");
586
+ if (fs.existsSync(targetSettingsPath)) {
587
+ const data = fs.readFileSync(targetSettingsPath, "utf-8");
359
588
  const config = JSON.parse(data);
360
589
  if (!config.beautifier) config.beautifier = {};
361
590
  config.beautifier.style = styleKey;
362
- const tempSettings = `${settingsPath}.tmp.${process.pid}.${Date.now()}`;
591
+ const tempSettings = `${targetSettingsPath}.tmp.${process.pid}.${Date.now()}`;
363
592
  fs.writeFileSync(tempSettings, JSON.stringify(config, null, 2), "utf-8");
364
- fs.renameSync(tempSettings, settingsPath);
593
+ fs.renameSync(tempSettings, targetSettingsPath);
365
594
  }
366
595
  } catch (_e) {
367
596
  // Silently ignore disk write errors
368
597
  }
598
+
599
+ for (const [key, val] of originalStatuses.entries()) {
600
+ ctx.ui.setStatus(key, val);
601
+ }
369
602
  }
370
603
  },
371
604
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tui-status-beautifier",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "Minimalist status bar formatter & AI-customizable layout engine for Pi TUI.",
5
5
  "main": "extensions/tui-status-beautifier.ts",
6
6
  "keywords": [