@pi-unipi/footer 0.1.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.
package/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # @pi-unipi/footer
2
+
3
+ Persistent status bar for the Unipi extension suite.
4
+
5
+ Subscribes to `UNIPI_EVENTS` and renders key stats from all unipi packages using pi's `setFooter` + `setWidget` APIs with responsive layout, presets, and per-segment toggling.
6
+
7
+ ## Features
8
+
9
+ - **Persistent status bar** — always-visible footer showing key stats from all unipi packages
10
+ - **Segment groups** — organized by package (core, compactor, memory, MCP, ralph, workflow, kanboard, notify)
11
+ - **Presets** — default, minimal, compact, full, nerd, ascii
12
+ - **Responsive layout** — adjusts to terminal width with secondary row overflow
13
+ - **Per-segment toggling** — enable/disable individual segments or entire groups
14
+ - **Theme integration** — uses pi's theme system with semantic colors
15
+ - **Nerd Font support** — auto-detection with ASCII fallback
16
+ - **Separator styles** — powerline, powerline-thin, slash, pipe, dot, ascii
17
+
18
+ ## Architecture
19
+
20
+ ```
21
+ ┌─────────────────────────────────────────────────────┐
22
+ │ FooterRenderer (setFooter + setWidget) │ ← Renders to screen
23
+ │ - Responsive layout (top + secondary rows) │
24
+ │ - Preset system, separators, theming │
25
+ ├─────────────────────────────────────────────────────┤
26
+ │ FooterRegistry (segment groups) │ ← Manages segments
27
+ │ - Subscribes to UNIPI_EVENTS │
28
+ │ - Per-segment enable/disable │
29
+ │ - Reactive data caching │
30
+ ├─────────────────────────────────────────────────────┤
31
+ │ Event Sources (existing packages) │ ← Data providers
32
+ │ - compactor, memory, workflow, ralph, mcp, │
33
+ │ kanboard, notify, core │
34
+ └─────────────────────────────────────────────────────┘
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ The footer is automatically enabled when unipi loads. Use commands to control it:
40
+
41
+ - `/unipi:footer` — toggle footer on/off
42
+ - `/unipi:footer <preset>` — switch preset (default, minimal, compact, full, nerd, ascii)
43
+ - `/unipi:footer sep:<style>` — change separator style (powerline, powerline-thin, slash, pipe, dot, ascii)
44
+ - `/unipi:footer icon:<style>` — change icon style (nerd, emoji, text)
45
+ - `/unipi:footer on` / `/unipi:footer off` — enable/disable explicitly
46
+ - `/unipi:footer-settings` — open settings TUI for per-group/per-segment toggles
47
+
48
+ ## Segment Groups
49
+
50
+ | Group | Segments | Default | Data Source |
51
+ |-------|----------|---------|-------------|
52
+ | **core** | `model`, `thinking`, `path`, `git`, `context_pct`, `cost`, `tokens_total`, `tokens_in`, `tokens_out`, `session`, `hostname`, `time` | ON (except hostname, time, tokens variants) | pi SDK (ctx.sessionManager, footerData) |
53
+ | **compactor** | `session_events`, `compactions`, `tokens_saved`, `compression_ratio`, `indexed_docs`, `sandbox_runs`, `search_queries` | ON (key stats only) | `COMPACTOR_STATS_UPDATED` event |
54
+ | **memory** | `project_count`, `total_count`, `consolidations` | ON | `MEMORY_STORED`/`DELETED`/`CONSOLIDATED` events |
55
+ | **mcp** | `servers_total`, `servers_active`, `tools_total`, `servers_failed` | ON | `MCP_SERVER_STARTED`/`STOPPED`/`ERROR` events |
56
+ | **ralph** | `active_loops`, `total_iterations`, `loop_status` | ON | `RALPH_LOOP_START`/`END`/`ITERATION_DONE` events |
57
+ | **workflow** | `current_command`, `sandbox_level`, `command_duration` | ON | `WORKFLOW_START`/`END` events |
58
+ | **kanboard** | `docs_count`, `tasks_done`, `tasks_total`, `task_pct` | ON | Kanboard registry (direct read) |
59
+ | **notify** | `platforms_enabled`, `last_sent` | OFF | `NOTIFICATION_SENT` event |
60
+ | **status_ext** | `extension_statuses` | ON | `footerData.getExtensionStatuses()` |
61
+
62
+ ## Presets
63
+
64
+ | Preset | Description | Key Segments |
65
+ |--------|-------------|-------------|
66
+ | `default` | Balanced view | model, thinking, path, git, context, cost + compactor + memory + ralph |
67
+ | `minimal` | Just the essentials | path, git, context |
68
+ | `compact` | Core + key stats | model, git, cost, context + compactor + memory |
69
+ | `full` | Everything | All segments from all groups |
70
+ | `nerd` | Maximum detail for Nerd Font users | full + hostname + time + session + extensions |
71
+ | `ascii` | Safe for any terminal | Core segments with ASCII icons |
72
+
73
+ ## Configuration
74
+
75
+ Settings are stored in `~/.pi/agent/settings.json` under `unipi.footer`:
76
+
77
+ ```json
78
+ {
79
+ "unipi": {
80
+ "footer": {
81
+ "enabled": true,
82
+ "preset": "default",
83
+ "separator": "powerline-thin",
84
+ "iconStyle": "nerd",
85
+ "groups": {
86
+ "compactor": {
87
+ "show": true,
88
+ "segments": {
89
+ "session_events": true,
90
+ "compactions": true,
91
+ "tokens_saved": true,
92
+ "compression_ratio": false,
93
+ "indexed_docs": false,
94
+ "sandbox_runs": false,
95
+ "search_queries": false
96
+ }
97
+ },
98
+ "memory": {
99
+ "show": true,
100
+ "segments": {
101
+ "project_count": true,
102
+ "total_count": true,
103
+ "consolidations": false
104
+ }
105
+ }
106
+ }
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ ## Responsive Layout
113
+
114
+ ```
115
+ Wide terminal (>120 cols):
116
+ ┌─ model │ thinking │ path │ git │ context │ cost │ compactions │ tokens_saved │ project_count ─┐
117
+ └──────────────────────────────────────────────────────────────────────────────────────────────┘
118
+
119
+ Narrow terminal (<120 cols):
120
+ ┌─ model │ thinking │ path │ git │ context │ cost ───────────────────────────────────────────────┐
121
+ └─ compactions │ tokens_saved │ project_count │ ralph │ workflow ────────────────────────────────┘
122
+ ```
123
+
124
+ ## Separator Styles
125
+
126
+ | Style | Look | Description |
127
+ |-------|------|-------------|
128
+ | `powerline` | ◀ ▶ | Thick powerline arrows |
129
+ | `powerline-thin` | | Thin powerline arrows (default) |
130
+ | `slash` | / | Slash separator |
131
+ | `pipe` | \| | Pipe separator |
132
+ | `dot` | · | Middle dot separator |
133
+ | `ascii` | > < | ASCII angle brackets |
134
+
135
+ ## Icon Styles
136
+
137
+ Three icon styles are available, controlled by `/unipi:footer icon:<style>` or the `iconStyle` setting:
138
+
139
+ | Style | Description | Example |
140
+ -------|-------------|--------|
141
+ | `nerd` | Nerd Font glyphs (default, requires Nerd Font terminal) | , , |
142
+ | `emoji` | Unicode emoji/symbols (works on most terminals) | ⚡, ◧, $] |
143
+ | `text` | Plain text abbreviations (works everywhere, most compact) | evt, cmp, $] |
144
+
145
+ When `iconStyle` is not explicitly set, the footer auto-detects Nerd Font support and
146
+ defaults to `nerd` if available, `emoji` otherwise.
147
+
148
+ ## Error Handling
149
+
150
+ - **Event subscription failures:** Each handler wrapped in try/catch — one failing handler doesn't break others
151
+ - **Data provider failures:** Segments hide when data unavailable (graceful degradation)
152
+ - **Config parse failures:** Fall back to default preset with warning
153
+ - **Module loading order:** Footer works even if packages load after it — late-arriving events update cache
154
+
155
+ ## Development
156
+
157
+ ```bash
158
+ # Run tests
159
+ pnpm test
160
+
161
+ # Type check
162
+ pnpm tsc --noEmit
163
+ ```
164
+
165
+ ## Package Structure
166
+
167
+ ```
168
+ packages/footer/
169
+ ├── index.ts # Re-exports
170
+ ├── types.ts # Re-exports from src/types.ts
171
+ ├── package.json # Package manifest
172
+ ├── tsconfig.json # TypeScript config
173
+ ├── README.md # This file
174
+ ├── src/
175
+ │ ├── index.ts # Extension entry point
176
+ │ ├── types.ts # Type definitions
177
+ │ ├── config.ts # Settings load/save
178
+ │ ├── events.ts # Event subscription wiring
179
+ │ ├── commands.ts # Command registration
180
+ │ ├── presets.ts # Preset definitions
181
+ │ ├── registry/ # FooterRegistry
182
+ │ │ └── index.ts
183
+ │ ├── rendering/ # Rendering engine
184
+ │ │ ├── renderer.ts # FooterRenderer class
185
+ │ │ ├── separators.ts # Separator system
186
+ │ │ ├── theme.ts # Theme color resolution
187
+ │ │ └── icons.ts # Icon system with Nerd Font detection
188
+ │ ├── segments/ # Segment implementations
189
+ │ │ ├── core.ts # Core segments (model, path, git, etc.)
190
+ │ │ ├── compactor.ts # Compactor segments
191
+ │ │ ├── memory.ts # Memory segments
192
+ │ │ ├── mcp.ts # MCP segments
193
+ │ │ ├── ralph.ts # Ralph segments
194
+ │ │ ├── workflow.ts # Workflow segments
195
+ │ │ ├── kanboard.ts # Kanboard segments
196
+ │ │ ├── notify.ts # Notify segments
197
+ │ │ └── status-ext.ts # Extension statuses segment
198
+ │ └── tui/
199
+ │ └── settings-tui.ts # Settings overlay TUI
200
+ └── tests/ # Unit tests
201
+ ├── separators.test.ts
202
+ ├── registry.test.ts
203
+ ├── config.test.ts
204
+ ├── segments.test.ts
205
+ └── events.test.ts
206
+ ```
package/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ /**
2
+ * @pi-unipi/footer — Re-exports
3
+ */
4
+
5
+ export { default } from "./src/index.js";
6
+ export * from "./src/types.js";
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@pi-unipi/footer",
3
+ "version": "0.1.1",
4
+ "description": "Persistent status bar for Unipi — subscribes to UNIPI_EVENTS and renders key stats from all unipi packages",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Neuron Mr White",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/Neuron-Mr-White/unipi.git",
11
+ "directory": "packages/footer"
12
+ },
13
+ "keywords": [
14
+ "pi-package",
15
+ "pi-extension",
16
+ "pi-coding-agent",
17
+ "unipi",
18
+ "footer",
19
+ "status-bar"
20
+ ],
21
+ "files": [
22
+ "index.ts",
23
+ "src/**/*.ts",
24
+ "skills/**/*",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "src/index.ts"
30
+ ],
31
+ "skills": [
32
+ "skills"
33
+ ]
34
+ },
35
+ "scripts": {
36
+ "test": "node --experimental-strip-types --test tests/**/*.test.ts"
37
+ },
38
+ "publishConfig": {
39
+ "access": "public"
40
+ },
41
+ "dependencies": {
42
+ "@pi-unipi/core": "*"
43
+ },
44
+ "peerDependencies": {
45
+ "@mariozechner/pi-coding-agent": "*",
46
+ "@mariozechner/pi-tui": "*"
47
+ },
48
+ "devDependencies": {
49
+ "@types/node": "^25.6.0",
50
+ "typescript": "^6.0.0"
51
+ }
52
+ }
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @pi-unipi/footer — Commands
3
+ *
4
+ * Footer commands: /unipi:footer (toggle), /unipi:footer <preset>,
5
+ * /unipi:footer-settings.
6
+ */
7
+
8
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
9
+ import { UNIPI_PREFIX, FOOTER_COMMANDS } from "@pi-unipi/core";
10
+ import { loadFooterSettings, saveFooterSettings } from "./config.js";
11
+ import { PRESET_NAMES } from "./presets.js";
12
+ import { showFooterSettings } from "./tui/settings-tui.js";
13
+ import type { FooterGroup, SeparatorStyle, IconStyle } from "./types.js";
14
+ import { setIconStyle } from "./rendering/icons.js";
15
+
16
+ /** Minimal autocomplete item (compatible with pi-tui AutocompleteItem) */
17
+ interface ArgSuggestion {
18
+ value: string;
19
+ label: string;
20
+ description?: string;
21
+ }
22
+
23
+ /** All valid separator styles */
24
+ const SEPARATOR_STYLES: SeparatorStyle[] = [
25
+ "powerline",
26
+ "powerline-thin",
27
+ "slash",
28
+ "pipe",
29
+ "dot",
30
+ "ascii",
31
+ ];
32
+
33
+ /** All valid icon styles */
34
+ const ICON_STYLES: IconStyle[] = [
35
+ "nerd",
36
+ "emoji",
37
+ "text",
38
+ ];
39
+
40
+ /** Extension state interface */
41
+ interface FooterState {
42
+ enabled: boolean;
43
+ renderer: {
44
+ setPreset(name: string): void;
45
+ setActive(active: boolean): void;
46
+ getPresetName(): string;
47
+ resetLayoutCache(): void;
48
+ };
49
+ piContext: unknown;
50
+ }
51
+
52
+ /**
53
+ * Register footer commands.
54
+ */
55
+ export function registerCommands(
56
+ pi: ExtensionAPI,
57
+ state: FooterState,
58
+ groups?: FooterGroup[],
59
+ ): void {
60
+ // /unipi:footer — toggle or switch preset
61
+ pi.registerCommand(`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER}`, {
62
+ description: "Toggle footer or switch preset (default, minimal, compact, full, nerd, ascii)",
63
+ getArgumentCompletions(argumentPrefix: string): ArgSuggestion[] | null {
64
+ const allOptions: ArgSuggestion[] = [
65
+ ...PRESET_NAMES.map(p => ({
66
+ value: p,
67
+ label: p,
68
+ description: `Switch to ${p} preset`,
69
+ })),
70
+ ...SEPARATOR_STYLES.map(s => ({
71
+ value: `sep:${s}`,
72
+ label: `sep:${s}`,
73
+ description: `Set separator style: ${s}`,
74
+ })),
75
+ ...ICON_STYLES.map(s => ({
76
+ value: `icon:${s}`,
77
+ label: `icon:${s}`,
78
+ description: `Set icon style: ${s}`,
79
+ })),
80
+ {
81
+ value: "on",
82
+ label: "on",
83
+ description: "Enable footer",
84
+ },
85
+ {
86
+ value: "off",
87
+ label: "off",
88
+ description: "Disable footer",
89
+ },
90
+ ];
91
+
92
+ if (!argumentPrefix) return allOptions;
93
+
94
+ const prefix = argumentPrefix.toLowerCase();
95
+ const filtered = allOptions.filter(o =>
96
+ o.value.toLowerCase().startsWith(prefix),
97
+ );
98
+ return filtered.length > 0 ? filtered : null;
99
+ },
100
+ handler: async (args, ctx) => {
101
+ if (!args?.trim()) {
102
+ // Toggle on/off
103
+ state.enabled = !state.enabled;
104
+ state.renderer.setActive(state.enabled);
105
+
106
+ if (state.enabled) {
107
+ ctx.ui.notify("Footer enabled", "info");
108
+ } else {
109
+ ctx.ui.setFooter(undefined);
110
+ ctx.ui.setWidget("footer-top", undefined);
111
+ ctx.ui.setWidget("footer-secondary", undefined);
112
+ ctx.ui.notify("Footer disabled", "info");
113
+ }
114
+
115
+ saveFooterSettings({ enabled: state.enabled });
116
+ return;
117
+ }
118
+
119
+ const arg = args.trim().toLowerCase();
120
+
121
+ // on / off
122
+ if (arg === "on") {
123
+ state.enabled = true;
124
+ state.renderer.setActive(true);
125
+ saveFooterSettings({ enabled: true });
126
+ ctx.ui.notify("Footer enabled", "info");
127
+ return;
128
+ }
129
+ if (arg === "off") {
130
+ state.enabled = false;
131
+ state.renderer.setActive(false);
132
+ ctx.ui.setFooter(undefined);
133
+ ctx.ui.setWidget("footer-top", undefined);
134
+ ctx.ui.setWidget("footer-secondary", undefined);
135
+ saveFooterSettings({ enabled: false });
136
+ ctx.ui.notify("Footer disabled", "info");
137
+ return;
138
+ }
139
+
140
+ // sep:<style> — change separator
141
+ if (arg.startsWith("sep:")) {
142
+ const style = arg.slice(4) as SeparatorStyle;
143
+ if (SEPARATOR_STYLES.includes(style)) {
144
+ saveFooterSettings({ separator: style });
145
+ state.renderer.resetLayoutCache();
146
+ ctx.ui.notify(`Separator: ${style}`, "info");
147
+ return;
148
+ }
149
+ ctx.ui.notify(`Unknown separator. Available: ${SEPARATOR_STYLES.join(", ")}`, "warning");
150
+ return;
151
+ }
152
+
153
+ // icon:<style> — change icon style
154
+ if (arg.startsWith("icon:")) {
155
+ const style = arg.slice(5) as IconStyle;
156
+ if (ICON_STYLES.includes(style)) {
157
+ saveFooterSettings({ iconStyle: style });
158
+ setIconStyle(style);
159
+ state.renderer.resetLayoutCache();
160
+ ctx.ui.notify(`Icon style: ${style}`, "info");
161
+ return;
162
+ }
163
+ ctx.ui.notify(`Unknown icon style. Available: ${ICON_STYLES.join(", ")}`, "warning");
164
+ return;
165
+ }
166
+
167
+ // Preset name
168
+ if (PRESET_NAMES.includes(arg)) {
169
+ state.renderer.setPreset(arg);
170
+ saveFooterSettings({ preset: arg });
171
+ ctx.ui.notify(`Footer preset: ${arg}`, "info");
172
+ return;
173
+ }
174
+
175
+ ctx.ui.notify(`Unknown argument. Use a preset (${PRESET_NAMES.join(", ")}), sep:<style>, icon:<style>, on, or off`, "info");
176
+ },
177
+ });
178
+
179
+ // /unipi:footer-settings — open settings TUI
180
+ pi.registerCommand(`${UNIPI_PREFIX}${FOOTER_COMMANDS.FOOTER_SETTINGS}`, {
181
+ description: "Open footer settings (toggle groups and segments)",
182
+ handler: async (_args, ctx) => {
183
+ if (!ctx.hasUI) {
184
+ ctx.ui.notify("Footer settings requires a TUI", "warning");
185
+ return;
186
+ }
187
+
188
+ if (groups && groups.length > 0) {
189
+ showFooterSettings(ctx, groups);
190
+ } else {
191
+ // Fallback: show text summary
192
+ const settings = loadFooterSettings();
193
+ const info = [
194
+ `Enabled: ${settings.enabled}`,
195
+ `Preset: ${state.renderer.getPresetName()}`,
196
+ `Separator: ${settings.separator}`,
197
+ `Icon: ${settings.iconStyle}`,
198
+ `Groups: ${Object.entries(settings.groups).filter(([, g]) => g.show).map(([id]) => id).join(", ")}`,
199
+ ].join("\n");
200
+ ctx.ui.notify(info, "info");
201
+ }
202
+ },
203
+ });
204
+ }
package/src/config.ts ADDED
@@ -0,0 +1,177 @@
1
+ /**
2
+ * @pi-unipi/footer — Configuration system
3
+ *
4
+ * Loads/saves footer settings from ~/.pi/agent/settings.json
5
+ * under the `unipi.footer` key.
6
+ */
7
+
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import * as os from "node:os";
11
+ import type { FooterSettings, FooterGroupSettings, SeparatorStyle, IconStyle } from "./types.js";
12
+ import { UNIPI_SETTINGS_KEY } from "@pi-unipi/core";
13
+
14
+ /** Default footer settings */
15
+ export const DEFAULT_FOOTER_SETTINGS: FooterSettings = {
16
+ enabled: true,
17
+ preset: "default",
18
+ separator: "powerline-thin",
19
+ iconStyle: "nerd",
20
+ groups: {
21
+ core: { show: true, segments: {} },
22
+ compactor: { show: true, segments: {} },
23
+ memory: { show: true, segments: {} },
24
+ mcp: { show: true, segments: {} },
25
+ ralph: { show: true, segments: {} },
26
+ workflow: { show: true, segments: {} },
27
+ kanboard: { show: true, segments: {} },
28
+ notify: { show: false, segments: {} },
29
+ status_ext: { show: true, segments: {} },
30
+ },
31
+ };
32
+
33
+ /**
34
+ * Get the path to pi's settings.json
35
+ */
36
+ function getSettingsPath(): string {
37
+ const agentDir = process.env.PI_AGENT_DIR || path.join(os.homedir(), ".pi", "agent");
38
+ return path.join(agentDir, "settings.json");
39
+ }
40
+
41
+ /**
42
+ * Read the raw settings.json file.
43
+ * Returns null if file doesn't exist or is malformed.
44
+ */
45
+ function readSettingsFile(): Record<string, unknown> | null {
46
+ try {
47
+ const settingsPath = getSettingsPath();
48
+ if (!fs.existsSync(settingsPath)) return null;
49
+ const raw = fs.readFileSync(settingsPath, "utf-8");
50
+ return JSON.parse(raw) as Record<string, unknown>;
51
+ } catch (err) {
52
+ console.warn("[footer] Failed to read settings.json:", err);
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Write settings back to settings.json.
59
+ */
60
+ function writeSettingsFile(settings: Record<string, unknown>): boolean {
61
+ try {
62
+ const settingsPath = getSettingsPath();
63
+ const dir = path.dirname(settingsPath);
64
+ if (!fs.existsSync(dir)) {
65
+ fs.mkdirSync(dir, { recursive: true });
66
+ }
67
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf-8");
68
+ return true;
69
+ } catch (err) {
70
+ console.warn("[footer] Failed to write settings.json:", err);
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Load footer settings from settings.json.
77
+ * Falls back to defaults for any missing fields.
78
+ */
79
+ export function loadFooterSettings(): FooterSettings {
80
+ const raw = readSettingsFile();
81
+ if (!raw) return { ...DEFAULT_FOOTER_SETTINGS };
82
+
83
+ try {
84
+ const unipi = raw[UNIPI_SETTINGS_KEY] as Record<string, unknown> | undefined;
85
+ if (!unipi) return { ...DEFAULT_FOOTER_SETTINGS };
86
+
87
+ const footer = unipi.footer as Record<string, unknown> | undefined;
88
+ if (!footer) return { ...DEFAULT_FOOTER_SETTINGS };
89
+
90
+ return {
91
+ enabled: typeof footer.enabled === "boolean" ? footer.enabled : DEFAULT_FOOTER_SETTINGS.enabled,
92
+ preset: typeof footer.preset === "string" ? footer.preset : DEFAULT_FOOTER_SETTINGS.preset,
93
+ separator: isValidSeparator(footer.separator) ? footer.separator as SeparatorStyle : DEFAULT_FOOTER_SETTINGS.separator,
94
+ iconStyle: isValidIconStyle(footer.iconStyle) ? footer.iconStyle as IconStyle : DEFAULT_FOOTER_SETTINGS.iconStyle,
95
+ groups: mergeGroupSettings(
96
+ DEFAULT_FOOTER_SETTINGS.groups,
97
+ footer.groups as Record<string, FooterGroupSettings> | undefined,
98
+ ),
99
+ };
100
+ } catch (err) {
101
+ console.warn("[footer] Failed to parse footer settings, using defaults:", err);
102
+ return { ...DEFAULT_FOOTER_SETTINGS };
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Save footer settings to settings.json.
108
+ * Merges with existing settings (preserves other keys).
109
+ */
110
+ export function saveFooterSettings(partial: Partial<FooterSettings>): boolean {
111
+ const raw = readSettingsFile() ?? {};
112
+ const unipi = (raw[UNIPI_SETTINGS_KEY] as Record<string, unknown>) ?? {};
113
+ const existing = (unipi.footer as Record<string, unknown>) ?? {};
114
+
115
+ unipi.footer = { ...existing, ...partial };
116
+ raw[UNIPI_SETTINGS_KEY] = unipi;
117
+
118
+ return writeSettingsFile(raw);
119
+ }
120
+
121
+ /**
122
+ * Get settings for a specific group.
123
+ * Falls back to defaults if group not configured.
124
+ */
125
+ export function getGroupSettings(groupId: string): FooterGroupSettings {
126
+ const settings = loadFooterSettings();
127
+ return settings.groups[groupId] ?? { show: true, segments: {} };
128
+ }
129
+
130
+ /**
131
+ * Check if a specific segment is enabled.
132
+ * Respects both group-level and segment-level settings.
133
+ */
134
+ export function isSegmentEnabled(groupId: string, segmentId: string): boolean {
135
+ const groupSettings = getGroupSettings(groupId);
136
+ if (!groupSettings.show) return false;
137
+ if (groupSettings.segments && segmentId in groupSettings.segments) {
138
+ return groupSettings.segments[segmentId] ?? true;
139
+ }
140
+ return true;
141
+ }
142
+
143
+ // ─── Helpers ────────────────────────────────────────────────────────────────
144
+
145
+ function isValidSeparator(value: unknown): boolean {
146
+ if (typeof value !== "string") return false;
147
+ const valid: string[] = ["powerline", "powerline-thin", "slash", "pipe", "dot", "ascii"];
148
+ return valid.includes(value);
149
+ }
150
+
151
+ function isValidIconStyle(value: unknown): boolean {
152
+ if (typeof value !== "string") return false;
153
+ const valid: string[] = ["nerd", "emoji", "text"];
154
+ return valid.includes(value);
155
+ }
156
+
157
+ function mergeGroupSettings(
158
+ defaults: Record<string, FooterGroupSettings>,
159
+ overrides: Record<string, FooterGroupSettings> | undefined,
160
+ ): Record<string, FooterGroupSettings> {
161
+ const result: Record<string, FooterGroupSettings> = { ...defaults };
162
+
163
+ if (!overrides) return result;
164
+
165
+ for (const [groupId, groupOverride] of Object.entries(overrides)) {
166
+ const defaultGroup = result[groupId] ?? { show: true, segments: {} };
167
+ result[groupId] = {
168
+ show: typeof groupOverride.show === "boolean" ? groupOverride.show : defaultGroup.show,
169
+ segments: {
170
+ ...defaultGroup.segments,
171
+ ...(groupOverride.segments ?? {}),
172
+ },
173
+ };
174
+ }
175
+
176
+ return result;
177
+ }