@herbertgao/pi-extensions 2026.8.3 → 2026.8.4

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.
Files changed (122) hide show
  1. package/README.md +41 -0
  2. package/examples/pi-footer.json +275 -0
  3. package/node_modules/pi-footer/CHANGELOG.md +145 -0
  4. package/node_modules/pi-footer/LICENSE +21 -0
  5. package/node_modules/pi-footer/README.md +354 -0
  6. package/node_modules/pi-footer/package.json +68 -0
  7. package/node_modules/pi-footer/src/cache.ts +106 -0
  8. package/node_modules/pi-footer/src/colors.ts +237 -0
  9. package/node_modules/pi-footer/src/config.ts +202 -0
  10. package/node_modules/pi-footer/src/event-widgets.ts +41 -0
  11. package/node_modules/pi-footer/src/extension-statuses.ts +102 -0
  12. package/node_modules/pi-footer/src/git.ts +135 -0
  13. package/node_modules/pi-footer/src/index.ts +268 -0
  14. package/node_modules/pi-footer/src/metrics.ts +141 -0
  15. package/node_modules/pi-footer/src/presets.ts +383 -0
  16. package/node_modules/pi-footer/src/render.ts +114 -0
  17. package/node_modules/pi-footer/src/separators.ts +52 -0
  18. package/node_modules/pi-footer/src/types.ts +122 -0
  19. package/node_modules/pi-footer/src/ui/color-level-confirm.ts +22 -0
  20. package/node_modules/pi-footer/src/ui/color-options.ts +195 -0
  21. package/node_modules/pi-footer/src/ui/config-lifecycle.ts +61 -0
  22. package/node_modules/pi-footer/src/ui/edit-colors.ts +13 -0
  23. package/node_modules/pi-footer/src/ui/events.ts +35 -0
  24. package/node_modules/pi-footer/src/ui/extension-status-picker.ts +60 -0
  25. package/node_modules/pi-footer/src/ui/extension-statuses.ts +73 -0
  26. package/node_modules/pi-footer/src/ui/fields.ts +252 -0
  27. package/node_modules/pi-footer/src/ui/global-menu.ts +126 -0
  28. package/node_modules/pi-footer/src/ui/helpers.ts +44 -0
  29. package/node_modules/pi-footer/src/ui/layout.ts +17 -0
  30. package/node_modules/pi-footer/src/ui/line-list.ts +19 -0
  31. package/node_modules/pi-footer/src/ui/model.ts +64 -0
  32. package/node_modules/pi-footer/src/ui/navigation.ts +29 -0
  33. package/node_modules/pi-footer/src/ui/option-edit.ts +93 -0
  34. package/node_modules/pi-footer/src/ui/overlay-render.ts +103 -0
  35. package/node_modules/pi-footer/src/ui/screen-context.ts +18 -0
  36. package/node_modules/pi-footer/src/ui/screen-controller.ts +27 -0
  37. package/node_modules/pi-footer/src/ui/screen-render.ts +32 -0
  38. package/node_modules/pi-footer/src/ui/screen-state.ts +20 -0
  39. package/node_modules/pi-footer/src/ui/screen.ts +243 -0
  40. package/node_modules/pi-footer/src/ui/screens/add-widget.ts +92 -0
  41. package/node_modules/pi-footer/src/ui/screens/confirm-exit.ts +52 -0
  42. package/node_modules/pi-footer/src/ui/screens/controller.ts +12 -0
  43. package/node_modules/pi-footer/src/ui/screens/edit-colors.ts +68 -0
  44. package/node_modules/pi-footer/src/ui/screens/edit-widget.ts +115 -0
  45. package/node_modules/pi-footer/src/ui/screens/extension-status-row.ts +61 -0
  46. package/node_modules/pi-footer/src/ui/screens/global.ts +75 -0
  47. package/node_modules/pi-footer/src/ui/screens/line-list.ts +108 -0
  48. package/node_modules/pi-footer/src/ui/screens/main.ts +99 -0
  49. package/node_modules/pi-footer/src/ui/screens/terminal.ts +121 -0
  50. package/node_modules/pi-footer/src/ui/screens/widget-list.ts +154 -0
  51. package/node_modules/pi-footer/src/ui/terminal-menu.ts +32 -0
  52. package/node_modules/pi-footer/src/ui/theme.ts +48 -0
  53. package/node_modules/pi-footer/src/ui/title-bar.ts +100 -0
  54. package/node_modules/pi-footer/src/ui/widget-actions.ts +76 -0
  55. package/node_modules/pi-footer/src/ui.ts +66 -0
  56. package/node_modules/pi-footer/src/widgets/context.ts +28 -0
  57. package/node_modules/pi-footer/src/widgets/core/active-tools.ts +17 -0
  58. package/node_modules/pi-footer/src/widgets/core/context-window.ts +24 -0
  59. package/node_modules/pi-footer/src/widgets/core/cwd-basename.ts +19 -0
  60. package/node_modules/pi-footer/src/widgets/core/cwd.ts +108 -0
  61. package/node_modules/pi-footer/src/widgets/core/event.ts +34 -0
  62. package/node_modules/pi-footer/src/widgets/core/external-status.ts +102 -0
  63. package/node_modules/pi-footer/src/widgets/core/model-provider.ts +20 -0
  64. package/node_modules/pi-footer/src/widgets/core/model.ts +35 -0
  65. package/node_modules/pi-footer/src/widgets/core/provider.ts +17 -0
  66. package/node_modules/pi-footer/src/widgets/core/session-name.ts +17 -0
  67. package/node_modules/pi-footer/src/widgets/core/text-verbosity.ts +17 -0
  68. package/node_modules/pi-footer/src/widgets/core/thinking-level.ts +17 -0
  69. package/node_modules/pi-footer/src/widgets/git/ahead-behind.ts +17 -0
  70. package/node_modules/pi-footer/src/widgets/git/branch.ts +81 -0
  71. package/node_modules/pi-footer/src/widgets/git/clean.ts +19 -0
  72. package/node_modules/pi-footer/src/widgets/git/deletions.ts +17 -0
  73. package/node_modules/pi-footer/src/widgets/git/diff.ts +38 -0
  74. package/node_modules/pi-footer/src/widgets/git/insertions.ts +17 -0
  75. package/node_modules/pi-footer/src/widgets/git/remote.ts +17 -0
  76. package/node_modules/pi-footer/src/widgets/git/root.ts +17 -0
  77. package/node_modules/pi-footer/src/widgets/git/sha.ts +17 -0
  78. package/node_modules/pi-footer/src/widgets/git/staged.ts +17 -0
  79. package/node_modules/pi-footer/src/widgets/git/status.ts +19 -0
  80. package/node_modules/pi-footer/src/widgets/git/unstaged.ts +17 -0
  81. package/node_modules/pi-footer/src/widgets/git/untracked.ts +17 -0
  82. package/node_modules/pi-footer/src/widgets/instance.ts +118 -0
  83. package/node_modules/pi-footer/src/widgets/layout/custom-text.ts +18 -0
  84. package/node_modules/pi-footer/src/widgets/layout/flex-separator.ts +32 -0
  85. package/node_modules/pi-footer/src/widgets/layout/separator.ts +57 -0
  86. package/node_modules/pi-footer/src/widgets/layout/spacer.ts +31 -0
  87. package/node_modules/pi-footer/src/widgets/options.ts +122 -0
  88. package/node_modules/pi-footer/src/widgets/project/runtime.ts +361 -0
  89. package/node_modules/pi-footer/src/widgets/registry.ts +223 -0
  90. package/node_modules/pi-footer/src/widgets/session/assistant-messages.ts +17 -0
  91. package/node_modules/pi-footer/src/widgets/session/compactions.ts +17 -0
  92. package/node_modules/pi-footer/src/widgets/session/elapsed.ts +18 -0
  93. package/node_modules/pi-footer/src/widgets/session/last-activity.ts +18 -0
  94. package/node_modules/pi-footer/src/widgets/session/messages.ts +19 -0
  95. package/node_modules/pi-footer/src/widgets/session/session-id.ts +17 -0
  96. package/node_modules/pi-footer/src/widgets/session/session-start.ts +18 -0
  97. package/node_modules/pi-footer/src/widgets/session/tool-results.ts +17 -0
  98. package/node_modules/pi-footer/src/widgets/session/total-messages.ts +19 -0
  99. package/node_modules/pi-footer/src/widgets/session/total-time.ts +18 -0
  100. package/node_modules/pi-footer/src/widgets/session/user-messages.ts +17 -0
  101. package/node_modules/pi-footer/src/widgets/store.ts +26 -0
  102. package/node_modules/pi-footer/src/widgets/tokens/cache-hit-rate.ts +55 -0
  103. package/node_modules/pi-footer/src/widgets/tokens/cache-read.ts +18 -0
  104. package/node_modules/pi-footer/src/widgets/tokens/cache-write.ts +18 -0
  105. package/node_modules/pi-footer/src/widgets/tokens/context-bar.ts +108 -0
  106. package/node_modules/pi-footer/src/widgets/tokens/context-length.ts +26 -0
  107. package/node_modules/pi-footer/src/widgets/tokens/context-remaining.ts +29 -0
  108. package/node_modules/pi-footer/src/widgets/tokens/context.ts +29 -0
  109. package/node_modules/pi-footer/src/widgets/tokens/cost.ts +51 -0
  110. package/node_modules/pi-footer/src/widgets/tokens/input-speed.ts +25 -0
  111. package/node_modules/pi-footer/src/widgets/tokens/input-tokens.ts +18 -0
  112. package/node_modules/pi-footer/src/widgets/tokens/output-speed.ts +25 -0
  113. package/node_modules/pi-footer/src/widgets/tokens/output-tokens.ts +18 -0
  114. package/node_modules/pi-footer/src/widgets/tokens/tokens.ts +20 -0
  115. package/node_modules/pi-footer/src/widgets/tokens/total-speed.ts +25 -0
  116. package/node_modules/pi-footer/src/widgets/tokens/total-tokens.ts +18 -0
  117. package/node_modules/pi-footer/src/widgets/types.ts +181 -0
  118. package/node_modules/pi-footer/src/widgets/utils/colors.ts +11 -0
  119. package/node_modules/pi-footer/src/widgets/utils/context.ts +112 -0
  120. package/node_modules/pi-footer/src/widgets/utils/session.ts +19 -0
  121. package/node_modules/pi-footer/src/widgets/utils/token-format.ts +78 -0
  122. package/package.json +7 -2
@@ -0,0 +1,237 @@
1
+ import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
2
+ import { Chalk } from "chalk";
3
+
4
+ const chalk = {
5
+ level: 2,
6
+ c: new Chalk({ level: 2 }),
7
+ };
8
+
9
+ export const COLOR_LEVEL_VALUES = ["truecolor", "ansi256", "ansi16", "none"] as const;
10
+
11
+ export type ColorLevel = (typeof COLOR_LEVEL_VALUES)[number];
12
+ export type ColorName =
13
+ | "default"
14
+ | "black"
15
+ | "red"
16
+ | "green"
17
+ | "yellow"
18
+ | "blue"
19
+ | "magenta"
20
+ | "cyan"
21
+ | "white"
22
+ | "brightBlack"
23
+ | "brightRed"
24
+ | "brightGreen"
25
+ | "brightYellow"
26
+ | "brightBlue"
27
+ | "brightMagenta"
28
+ | "brightCyan"
29
+ | "brightWhite"
30
+ | `ansi256:${number}`
31
+ | `pi:${ThemeColor}`;
32
+
33
+ interface ColorChoice {
34
+ label: string;
35
+ value: ColorName;
36
+ }
37
+
38
+ export const STANDARD_COLORS: ColorChoice[] = [
39
+ { label: "Default", value: "default" },
40
+ { label: "Black", value: "black" },
41
+ { label: "Red", value: "red" },
42
+ { label: "Green", value: "green" },
43
+ { label: "Yellow", value: "yellow" },
44
+ { label: "Blue", value: "blue" },
45
+ { label: "Magenta", value: "magenta" },
46
+ { label: "Cyan", value: "cyan" },
47
+ { label: "White", value: "white" },
48
+ { label: "Bright Black", value: "brightBlack" },
49
+ { label: "Bright Red", value: "brightRed" },
50
+ { label: "Bright Green", value: "brightGreen" },
51
+ { label: "Bright Yellow", value: "brightYellow" },
52
+ { label: "Bright Blue", value: "brightBlue" },
53
+ { label: "Bright Magenta", value: "brightMagenta" },
54
+ { label: "Bright Cyan", value: "brightCyan" },
55
+ { label: "Bright White", value: "brightWhite" },
56
+ ];
57
+
58
+ const PI_THEME_COLORS: ThemeColor[] = [
59
+ "accent",
60
+ "border",
61
+ "borderAccent",
62
+ "borderMuted",
63
+ "success",
64
+ "error",
65
+ "warning",
66
+ "muted",
67
+ "dim",
68
+ "text",
69
+ "thinkingText",
70
+ "userMessageText",
71
+ "customMessageText",
72
+ "customMessageLabel",
73
+ "toolTitle",
74
+ "toolOutput",
75
+ "toolDiffAdded",
76
+ "toolDiffRemoved",
77
+ "toolDiffContext",
78
+ "syntaxType",
79
+ "thinkingOff",
80
+ "thinkingMinimal",
81
+ "thinkingLow",
82
+ "thinkingMedium",
83
+ "thinkingHigh",
84
+ "thinkingXhigh",
85
+ "bashMode",
86
+ ];
87
+
88
+ const PI_FOREGROUND_COLORS: ColorChoice[] = PI_THEME_COLORS.map((color) => ({
89
+ label: `Pi ${themeColorDisplayName(color)}`,
90
+ value: `pi:${color}`,
91
+ }));
92
+
93
+ export const FOREGROUND_COLORS: ColorChoice[] = [...STANDARD_COLORS, ...PI_FOREGROUND_COLORS];
94
+
95
+ const ANSI16_FG: Record<
96
+ Exclude<ColorName, `ansi256:${number}` | `pi:${ThemeColor}`>,
97
+ [number, number]
98
+ > = {
99
+ default: [39, 49],
100
+ black: [30, 40],
101
+ red: [31, 41],
102
+ green: [32, 42],
103
+ yellow: [33, 43],
104
+ blue: [34, 44],
105
+ magenta: [35, 45],
106
+ cyan: [36, 46],
107
+ white: [37, 47],
108
+ brightBlack: [90, 100],
109
+ brightRed: [91, 101],
110
+ brightGreen: [92, 102],
111
+ brightYellow: [93, 103],
112
+ brightBlue: [94, 104],
113
+ brightMagenta: [95, 105],
114
+ brightCyan: [96, 106],
115
+ brightWhite: [97, 107],
116
+ };
117
+
118
+ export function normalizeColor(value: unknown): ColorName | undefined {
119
+ if (typeof value !== "string") return undefined;
120
+ if (STANDARD_COLORS.some((color) => color.value === value)) return value as ColorName;
121
+ if (PI_FOREGROUND_COLORS.some((color) => color.value === value)) return value as ColorName;
122
+ const match = /^ansi256:([0-9]{1,3})$/.exec(value);
123
+ if (!match) return undefined;
124
+ const code = Number(match[1]);
125
+ return Number.isInteger(code) && code >= 0 && code <= 255
126
+ ? (`ansi256:${code}` as ColorName)
127
+ : undefined;
128
+ }
129
+
130
+ export function colorDisplayName(color: ColorName | undefined): string {
131
+ if (!color || color === "default") return "Default";
132
+ if (color.startsWith("ansi256:")) return `ANSI256 ${color.slice("ansi256:".length)}`;
133
+ if (color.startsWith("pi:"))
134
+ return PI_FOREGROUND_COLORS.find((entry) => entry.value === color)?.label ?? color;
135
+ return STANDARD_COLORS.find((entry) => entry.value === color)?.label ?? color;
136
+ }
137
+
138
+ export function ansi256Digits(color: ColorName | undefined): string {
139
+ return color?.startsWith("ansi256:") ? String(Number(color.slice("ansi256:".length))) : "0";
140
+ }
141
+
142
+ export function appendAnsi256Digit(color: ColorName | undefined, digit: string): ColorName {
143
+ const digits = ansi256Digits(color);
144
+ const next = Number(`${digits}${digit}`);
145
+ return `ansi256:${Math.min(255, next)}`;
146
+ }
147
+
148
+ export function deleteAnsi256Digit(color: ColorName | undefined): ColorName {
149
+ const digits = ansi256Digits(color) || "0";
150
+ const next = digits.slice(0, -1);
151
+ return `ansi256:${next === "" ? 0 : Number(next)}`;
152
+ }
153
+
154
+ function useColorLevel(level: ColorLevel) {
155
+ const next = level === "truecolor" ? 3 : level === "ansi256" ? 2 : 1;
156
+ if (chalk.level === next) return;
157
+ chalk.level = next;
158
+ chalk.c = new Chalk({ level: next });
159
+ }
160
+
161
+ export function applyColors(
162
+ text: string,
163
+ foreground: ColorName | undefined,
164
+ background: ColorName | undefined,
165
+ bold: boolean | undefined,
166
+ level: ColorLevel,
167
+ theme?: Theme,
168
+ ): string {
169
+ if (level === "none") return text;
170
+ useColorLevel(level);
171
+
172
+ let output = text;
173
+ if (foreground && foreground !== "default")
174
+ output = applyOne(output, foreground, false, level, theme);
175
+ if (background && background !== "default") output = applyOne(output, background, true, level);
176
+ if (bold) output = chalk.c.bold(output);
177
+ return output;
178
+ }
179
+
180
+ // The styleable color bag shared by widget options and the context conditional-color helper.
181
+ // fg/bg are validated ColorName (color picker); the warning/danger quartet is raw text input
182
+ // (kind: "text" properties) normalized via normalizeColor() at read time.
183
+ export interface ConditionalColorFields {
184
+ fg?: ColorName;
185
+ bg?: ColorName;
186
+ warningFg?: string;
187
+ warningBg?: string;
188
+ dangerFg?: string;
189
+ dangerBg?: string;
190
+ }
191
+
192
+ export function resetAnsi256Colors<T extends ConditionalColorFields>(options: T): T {
193
+ const next = { ...options };
194
+ if (next.fg?.startsWith("ansi256:")) next.fg = "default";
195
+ if (next.bg?.startsWith("ansi256:")) next.bg = "default";
196
+ if (next.warningFg?.startsWith("ansi256:")) next.warningFg = "default";
197
+ if (next.warningBg?.startsWith("ansi256:")) next.warningBg = "default";
198
+ if (next.dangerFg?.startsWith("ansi256:")) next.dangerFg = "default";
199
+ if (next.dangerBg?.startsWith("ansi256:")) next.dangerBg = "default";
200
+ return next;
201
+ }
202
+
203
+ function themeColorDisplayName(color: ThemeColor): string {
204
+ return color.replace(/([A-Z])/g, " $1").replace(/^./, (char) => char.toUpperCase());
205
+ }
206
+
207
+ function applyOne(
208
+ text: string,
209
+ color: ColorName,
210
+ background: boolean,
211
+ level: ColorLevel,
212
+ theme?: Theme,
213
+ ): string {
214
+ if (color.startsWith("pi:")) {
215
+ if (background || !theme) return text;
216
+ return theme.fg(color.slice("pi:".length) as ThemeColor, text);
217
+ }
218
+ if (color.startsWith("ansi256:")) {
219
+ const code = Number(color.slice("ansi256:".length));
220
+ if (level === "ansi256" || level === "truecolor") {
221
+ return background ? chalk.c.bgAnsi256(code)(text) : chalk.c.ansi256(code)(text);
222
+ }
223
+ return text;
224
+ }
225
+
226
+ const codes = ANSI16_FG[color as Exclude<ColorName, `ansi256:${number}` | `pi:${ThemeColor}`>];
227
+ if (!codes || color === "default") return text;
228
+ return `\x1b[${background ? codes[1] : codes[0]}m${text}\x1b[${background ? 49 : 39}m`;
229
+ }
230
+
231
+ // Keep the escape byte out of a regex literal so oxlint's no-control-regex rule does not
232
+ // flag the intentional ANSI matcher. RegExp still receives the ESC sequence at runtime.
233
+ const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1B\[[0-?]*[ -/]*[@-~]`, "g");
234
+
235
+ export function stripAnsi(text: string): string {
236
+ return text.replace(ANSI_ESCAPE_PATTERN, "");
237
+ }
@@ -0,0 +1,202 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ import type { ColorLevel } from "./colors.js";
7
+ import { COLOR_LEVEL_VALUES, normalizeColor } from "./colors.js";
8
+ import {
9
+ cloneExtensionStatusRow,
10
+ DEFAULT_EXTENSION_STATUS_ROW,
11
+ normalizeExtensionStatusRow,
12
+ } from "./extension-statuses.js";
13
+ import {
14
+ PRESET_DEFINITIONS,
15
+ type Preset,
16
+ type PresetDefinition,
17
+ type PresetWidget,
18
+ } from "./presets.js";
19
+ import { SEPARATOR_VALUES, type SeparatorStyle } from "./separators.js";
20
+ import type {
21
+ IconMode,
22
+ StatuslineConfig,
23
+ StatuslineSettings,
24
+ TerminalOptions,
25
+ TerminalWidthMode,
26
+ WidgetEntry,
27
+ } from "./types.js";
28
+ import { ICON_MODE_VALUES, isRecord, TERMINAL_WIDTH_MODE_VALUES } from "./types.js";
29
+ import { registry, type WidgetType } from "./widgets/registry.js";
30
+
31
+ export const STATUS_KEY = "pi-footer";
32
+
33
+ const CONFIG_ENV = "PI_FOOTER_CONFIG";
34
+ const DEFAULT_CONFIG_PATH = join(getAgentDir(), "extensions", "pi-footer.json");
35
+ const SEPARATORS = new Set<SeparatorStyle>(SEPARATOR_VALUES);
36
+
37
+ const DEFAULT_TERMINAL_OPTIONS: TerminalOptions = {
38
+ widthMode: "full",
39
+ colorLevel: "ansi256",
40
+ };
41
+
42
+ export const DEFAULT_CONFIG: StatuslineConfig = {
43
+ version: 1,
44
+ enabled: true,
45
+ preset: "default",
46
+ lines: linesForPreset("default"),
47
+ separator: "dot",
48
+ separatorFg: "default",
49
+ separatorBg: "default",
50
+ iconMode: "emoji",
51
+ minimalist: false,
52
+ terminal: DEFAULT_TERMINAL_OPTIONS,
53
+ extensionStatusRow: DEFAULT_EXTENSION_STATUS_ROW,
54
+ };
55
+
56
+ export function getConfigPath(): string {
57
+ return process.env[CONFIG_ENV] ?? DEFAULT_CONFIG_PATH;
58
+ }
59
+
60
+ function linesForPreset(preset: Preset): WidgetEntry[][] {
61
+ return PRESET_DEFINITIONS[preset].lines.map((line) => widgetsFromPresetLine(line));
62
+ }
63
+
64
+ export function configWithPreset(config: StatuslineConfig, preset: Preset): StatuslineConfig {
65
+ const definition: PresetDefinition = PRESET_DEFINITIONS[preset];
66
+ return {
67
+ ...config,
68
+ preset,
69
+ lines: linesForPreset(preset),
70
+ separator: definition.separator ?? config.separator,
71
+ iconMode: definition.iconMode ?? config.iconMode,
72
+ terminal: { ...config.terminal, ...definition.terminal },
73
+ };
74
+ }
75
+
76
+ function widgetsFromPresetLine(line: readonly PresetWidget[]): WidgetEntry[] {
77
+ return line.map((widget) => registry.createEntry(widget.type, widget.options));
78
+ }
79
+
80
+ export function normalizeConfig(input: unknown): StatuslineConfig {
81
+ if (!isRecord(input)) return cloneConfig(DEFAULT_CONFIG);
82
+
83
+ const preset = isPreset(input.preset) ? input.preset : DEFAULT_CONFIG.preset;
84
+ const lines = normalizeLines(input.lines, preset);
85
+
86
+ return {
87
+ version: 1,
88
+ enabled: typeof input.enabled === "boolean" ? input.enabled : DEFAULT_CONFIG.enabled,
89
+ preset,
90
+ lines,
91
+ separator: isSeparatorStyle(input.separator)
92
+ ? input.separator
93
+ : (PRESET_DEFINITIONS[preset].separator ?? DEFAULT_CONFIG.separator),
94
+ separatorFg: normalizeColor(input.separatorFg) ?? DEFAULT_CONFIG.separatorFg,
95
+ separatorBg: normalizeColor(input.separatorBg) ?? DEFAULT_CONFIG.separatorBg,
96
+ iconMode: isIconMode(input.iconMode) ? input.iconMode : DEFAULT_CONFIG.iconMode,
97
+ minimalist:
98
+ typeof input.minimalist === "boolean" ? input.minimalist : DEFAULT_CONFIG.minimalist,
99
+ terminal: normalizeTerminalOptions(input.terminal, PRESET_DEFINITIONS[preset].terminal),
100
+ extensionStatusRow: normalizeExtensionStatusRow(input.extensionStatusRow),
101
+ };
102
+ }
103
+
104
+ export function cloneSettings(settings: StatuslineSettings): StatuslineSettings {
105
+ return {
106
+ ...settings,
107
+ terminal: { ...settings.terminal },
108
+ extensionStatusRow: cloneExtensionStatusRow(settings.extensionStatusRow),
109
+ };
110
+ }
111
+
112
+ export function cloneConfig(config: StatuslineConfig): StatuslineConfig {
113
+ return {
114
+ ...cloneSettings(config),
115
+ lines: config.lines.map((line) =>
116
+ line.map((widget) => ({ ...widget, options: { ...widget.options } })),
117
+ ),
118
+ };
119
+ }
120
+
121
+ export async function loadConfig(path = getConfigPath()): Promise<StatuslineConfig> {
122
+ try {
123
+ const raw = await readFile(path, "utf8");
124
+ return normalizeConfig(JSON.parse(raw));
125
+ } catch (error) {
126
+ if (isNodeError(error) && error.code === "ENOENT") {
127
+ return cloneConfig(DEFAULT_CONFIG);
128
+ }
129
+ throw error;
130
+ }
131
+ }
132
+
133
+ export async function saveConfig(config: StatuslineConfig, path = getConfigPath()): Promise<void> {
134
+ await mkdir(dirname(path), { recursive: true });
135
+ await writeFile(path, `${JSON.stringify(normalizeConfig(config), null, 2)}\n`, "utf8");
136
+ }
137
+
138
+ function normalizeLines(linesValue: unknown, preset: Preset): WidgetEntry[][] {
139
+ if (!Array.isArray(linesValue)) return linesForPreset(preset);
140
+ return linesValue.map((line) => normalizeWidgets(line));
141
+ }
142
+
143
+ function normalizeWidgets(value: unknown): WidgetEntry[] {
144
+ if (!Array.isArray(value)) return [];
145
+
146
+ const widgets: WidgetEntry[] = [];
147
+ for (const item of value) {
148
+ if (!isRecord(item) || typeof item.type !== "string") continue;
149
+ const spec = registry.maybeSpec(item.type);
150
+ if (!spec) continue;
151
+ widgets.push({
152
+ id:
153
+ typeof item.id === "string" && item.id.length > 0
154
+ ? item.id
155
+ : registry.createEntry(spec.type as WidgetType).id,
156
+ type: spec.type as WidgetType,
157
+ enabled: typeof item.enabled === "boolean" ? item.enabled : true,
158
+ options: registry.normalizeOptions(
159
+ spec.type as WidgetType,
160
+ isRecord(item.options) ? item.options : {},
161
+ ),
162
+ });
163
+ }
164
+
165
+ return widgets;
166
+ }
167
+
168
+ function normalizeTerminalOptions(
169
+ value: unknown,
170
+ defaults: Partial<TerminalOptions> = {},
171
+ ): TerminalOptions {
172
+ const base = { ...DEFAULT_TERMINAL_OPTIONS, ...defaults };
173
+ if (!isRecord(value)) return base;
174
+ return {
175
+ widthMode:
176
+ typeof value.widthMode === "string" &&
177
+ TERMINAL_WIDTH_MODE_VALUES.includes(value.widthMode as TerminalWidthMode)
178
+ ? (value.widthMode as TerminalWidthMode)
179
+ : base.widthMode,
180
+ colorLevel:
181
+ typeof value.colorLevel === "string" &&
182
+ COLOR_LEVEL_VALUES.includes(value.colorLevel as ColorLevel)
183
+ ? (value.colorLevel as ColorLevel)
184
+ : base.colorLevel,
185
+ };
186
+ }
187
+
188
+ export function isPreset(value: unknown): value is Preset {
189
+ return typeof value === "string" && Object.hasOwn(PRESET_DEFINITIONS, value);
190
+ }
191
+
192
+ function isSeparatorStyle(value: unknown): value is SeparatorStyle {
193
+ return typeof value === "string" && SEPARATORS.has(value as SeparatorStyle);
194
+ }
195
+
196
+ function isIconMode(value: unknown): value is IconMode {
197
+ return typeof value === "string" && ICON_MODE_VALUES.includes(value as IconMode);
198
+ }
199
+
200
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
201
+ return error instanceof Error && "code" in error;
202
+ }
@@ -0,0 +1,41 @@
1
+ import { isRecord } from "./types.js";
2
+
3
+ export const UPDATE_EVENT_WIDGET_EVENT = "pi-footer:update-widget";
4
+
5
+ interface UpdateEventWidgetPayload {
6
+ widgetId: string;
7
+ value: string | null;
8
+ }
9
+
10
+ const EVENT_WIDGET_ID_PREFIX = "event_";
11
+
12
+ export function createEventWidgetId(): string {
13
+ return `${EVENT_WIDGET_ID_PREFIX}${Math.random().toString(36).slice(2, 10)}`;
14
+ }
15
+
16
+ export class EventWidgetValues {
17
+ private readonly valuesById = new Map<string, string>();
18
+
19
+ get values(): ReadonlyMap<string, string> {
20
+ return this.valuesById;
21
+ }
22
+
23
+ update(payload: unknown): boolean {
24
+ if (!isUpdatePayload(payload)) return false;
25
+ if (payload.value === null) {
26
+ return this.valuesById.delete(payload.widgetId);
27
+ }
28
+ const previous = this.valuesById.get(payload.widgetId);
29
+ this.valuesById.set(payload.widgetId, payload.value);
30
+ return previous !== payload.value;
31
+ }
32
+ }
33
+
34
+ function isUpdatePayload(value: unknown): value is UpdateEventWidgetPayload {
35
+ if (!isRecord(value)) return false;
36
+ return (
37
+ typeof value.widgetId === "string" &&
38
+ value.widgetId.length > 0 &&
39
+ (typeof value.value === "string" || value.value === null)
40
+ );
41
+ }
@@ -0,0 +1,102 @@
1
+ import { isRecord } from "./types.js";
2
+
3
+ export const EMPTY_STATUS_LABEL = "[Empty status]";
4
+
5
+ export interface ExtensionStatusRowConfig {
6
+ hiddenKeys: string[];
7
+ knownKeys: string[];
8
+ }
9
+
10
+ interface ExtensionStatusEntry {
11
+ key: string;
12
+ value: string;
13
+ published: boolean;
14
+ }
15
+
16
+ export type GetExtensionStatuses = () => ReadonlyMap<string, string>;
17
+
18
+ export const DEFAULT_EXTENSION_STATUS_ROW: ExtensionStatusRowConfig = {
19
+ hiddenKeys: [],
20
+ knownKeys: [],
21
+ };
22
+
23
+ export const EMPTY_EXTENSION_STATUSES: ReadonlyMap<string, string> = new Map<string, string>();
24
+
25
+ export function extensionStatusEntries(
26
+ statuses: ReadonlyMap<string, string>,
27
+ ownStatusKey: string,
28
+ ): ExtensionStatusEntry[] {
29
+ return [...statuses.entries()]
30
+ .filter(([key, value]) => key !== ownStatusKey && value.length > 0)
31
+ .sort(([left], [right]) => left.localeCompare(right))
32
+ .map(([key, value]) => ({ key, value, published: true }));
33
+ }
34
+
35
+ export function allExtensionStatusEntries(
36
+ statuses: ReadonlyMap<string, string>,
37
+ rowConfig: ExtensionStatusRowConfig,
38
+ ownStatusKey: string,
39
+ ): ExtensionStatusEntry[] {
40
+ const keys = new Set([...statuses.keys(), ...rowConfig.hiddenKeys, ...rowConfig.knownKeys]);
41
+ return [...keys]
42
+ .filter((key) => key.length > 0 && key !== ownStatusKey)
43
+ .sort((left, right) => left.localeCompare(right))
44
+ .map((key) => {
45
+ const value = statuses.get(key) ?? "";
46
+ return {
47
+ key,
48
+ value: value.length > 0 ? value : EMPTY_STATUS_LABEL,
49
+ published: value.length > 0,
50
+ };
51
+ });
52
+ }
53
+
54
+ export function visibleExtensionStatusRowEntries(
55
+ statuses: ReadonlyMap<string, string>,
56
+ hiddenKeys: readonly string[],
57
+ ownStatusKey: string,
58
+ ): ExtensionStatusEntry[] {
59
+ const hidden = new Set(hiddenKeys);
60
+ return extensionStatusEntries(statuses, ownStatusKey).filter((entry) => !hidden.has(entry.key));
61
+ }
62
+
63
+ export function toggleExtensionStatusRowKey(
64
+ rowConfig: ExtensionStatusRowConfig,
65
+ key: string,
66
+ ): ExtensionStatusRowConfig {
67
+ const hidden = new Set(rowConfig.hiddenKeys);
68
+ const known = new Set(rowConfig.knownKeys);
69
+ known.add(key);
70
+ if (hidden.has(key)) hidden.delete(key);
71
+ else hidden.add(key);
72
+ return {
73
+ hiddenKeys: sortedKeys(hidden),
74
+ knownKeys: sortedKeys(known),
75
+ };
76
+ }
77
+
78
+ export function normalizeExtensionStatusRow(value: unknown): ExtensionStatusRowConfig {
79
+ if (!isRecord(value)) return cloneExtensionStatusRow(DEFAULT_EXTENSION_STATUS_ROW);
80
+ return {
81
+ hiddenKeys: normalizeKeyList(value.hiddenKeys),
82
+ knownKeys: normalizeKeyList(value.knownKeys),
83
+ };
84
+ }
85
+
86
+ export function cloneExtensionStatusRow(value: ExtensionStatusRowConfig): ExtensionStatusRowConfig {
87
+ return {
88
+ hiddenKeys: [...value.hiddenKeys],
89
+ knownKeys: [...value.knownKeys],
90
+ };
91
+ }
92
+
93
+ function normalizeKeyList(value: unknown): string[] {
94
+ if (!Array.isArray(value)) return [];
95
+ return sortedKeys(
96
+ new Set(value.filter((key): key is string => typeof key === "string" && key.length > 0)),
97
+ );
98
+ }
99
+
100
+ function sortedKeys(keys: Iterable<string>): string[] {
101
+ return [...keys].sort((left, right) => left.localeCompare(right));
102
+ }