rinco-pi-hud 1.0.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/NOTICE +18 -0
  3. package/README.md +204 -0
  4. package/docs/CONTRIBUTING.md +110 -0
  5. package/docs/README.zh-CN.md +204 -0
  6. package/docs/footer.md +139 -0
  7. package/extensions/hud/commands/settings.ts +765 -0
  8. package/extensions/hud/config/config-counts.ts +87 -0
  9. package/extensions/hud/config/config.ts +65 -0
  10. package/extensions/hud/config/file-io.ts +128 -0
  11. package/extensions/hud/config/normalize.ts +374 -0
  12. package/extensions/hud/config/persist.ts +277 -0
  13. package/extensions/hud/config/schema.ts +303 -0
  14. package/extensions/hud/footer/format.ts +279 -0
  15. package/extensions/hud/footer/index.ts +543 -0
  16. package/extensions/hud/footer/layout.ts +183 -0
  17. package/extensions/hud/footer/render-labels.ts +305 -0
  18. package/extensions/hud/index.ts +466 -0
  19. package/extensions/hud/segments/extension-status.ts +88 -0
  20. package/extensions/hud/segments/git.ts +384 -0
  21. package/extensions/hud/segments/mcp-status.ts +25 -0
  22. package/extensions/hud/segments/package-version.ts +651 -0
  23. package/extensions/hud/segments/project-state.ts +67 -0
  24. package/extensions/hud/segments/runtime-definitions.ts +695 -0
  25. package/extensions/hud/segments/runtime.ts +167 -0
  26. package/extensions/hud/segments/skill-activity.ts +152 -0
  27. package/extensions/hud/session/context.ts +19 -0
  28. package/extensions/hud/session/lifecycle.ts +60 -0
  29. package/extensions/hud/session/live-context.ts +75 -0
  30. package/extensions/hud/state/index.ts +67 -0
  31. package/extensions/hud/state/project-refresh.ts +104 -0
  32. package/extensions/hud/state/telemetry.ts +261 -0
  33. package/extensions/hud/telemetry/codex-usage/LICENSE +21 -0
  34. package/extensions/hud/telemetry/codex-usage/app-server-client.ts +231 -0
  35. package/extensions/hud/telemetry/codex-usage/format.ts +278 -0
  36. package/extensions/hud/telemetry/codex-usage/index.ts +462 -0
  37. package/extensions/hud/telemetry/codex-usage/normalize.ts +264 -0
  38. package/extensions/hud/telemetry/codex-usage/query.ts +207 -0
  39. package/extensions/hud/telemetry/codex-usage/safety.ts +27 -0
  40. package/extensions/hud/telemetry/codex-usage/types.ts +137 -0
  41. package/extensions/hud/telemetry/format.ts +434 -0
  42. package/extensions/hud/telemetry/token-switch.ts +114 -0
  43. package/extensions/hud/ui/icons.ts +239 -0
  44. package/extensions/hud/ui/style.ts +276 -0
  45. package/package.json +56 -0
@@ -0,0 +1,87 @@
1
+ import { createRequire } from "node:module";
2
+ import { readFileSync, statSync } from "node:fs";
3
+ import { isAbsolute, join } from "node:path";
4
+
5
+ export interface ConfigCounts {
6
+ instructionFiles: {
7
+ agentsMd: number;
8
+ claudeMd: number;
9
+ total: number;
10
+ };
11
+ packages: number;
12
+ }
13
+
14
+ export interface ConfigCountOptions {
15
+ /** Overrides Pi's agent directory; useful for isolated callers and tests. */
16
+ agentDir?: string;
17
+ /** Injection point for the Pi SDK resolver. The SDK resolver remains the default. */
18
+ getAgentDir?: () => string;
19
+ }
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const MAX_SETTINGS_BYTES = 1024 * 1024;
23
+
24
+ function getPiAgentDir(): string {
25
+ try {
26
+ const agentModule = require("@earendil-works/pi-coding-agent") as {
27
+ getAgentDir?: () => string;
28
+ };
29
+ return agentModule.getAgentDir?.() ?? "";
30
+ } catch {
31
+ return "";
32
+ }
33
+ }
34
+
35
+ function resolveAgentDir(options: ConfigCountOptions): string {
36
+ let agentDir = "";
37
+ try {
38
+ agentDir = typeof options.agentDir === "string"
39
+ ? options.agentDir
40
+ : options.getAgentDir
41
+ ? options.getAgentDir()
42
+ : getPiAgentDir();
43
+ } catch {
44
+ return "";
45
+ }
46
+ return agentDir && isAbsolute(agentDir) ? agentDir : "";
47
+ }
48
+
49
+ function countTopLevelFile(cwd: string, name: string): number {
50
+ try {
51
+ return statSync(join(cwd, name)).isFile() ? 1 : 0;
52
+ } catch {
53
+ return 0;
54
+ }
55
+ }
56
+
57
+ function countPackages(agentDir: string): number {
58
+ if (!agentDir) return 0;
59
+ try {
60
+ const settingsPath = join(agentDir, "settings.json");
61
+ const stats = statSync(settingsPath);
62
+ if (!stats.isFile() || stats.size > MAX_SETTINGS_BYTES) return 0;
63
+ const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as {
64
+ packages?: unknown;
65
+ };
66
+ return Array.isArray(settings?.packages) ? settings.packages.length : 0;
67
+ } catch {
68
+ return 0;
69
+ }
70
+ }
71
+
72
+ export function countConfigEntries(cwd: string, options: ConfigCountOptions = {}): ConfigCounts {
73
+ const agentDir = resolveAgentDir(options);
74
+ const agentsMd = countTopLevelFile(cwd, "AGENTS.md");
75
+ const claudeMd = countTopLevelFile(cwd, "CLAUDE.md");
76
+
77
+ return {
78
+ instructionFiles: {
79
+ agentsMd,
80
+ claudeMd,
81
+ total: agentsMd + claudeMd,
82
+ },
83
+ packages: countPackages(agentDir),
84
+ };
85
+ }
86
+
87
+ export const getConfigCounts = countConfigEntries;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Public barrel for the Zentui config module.
3
+ *
4
+ * The config surface is split across four focused files:
5
+ * - `schema.ts` — types, format-string vocabulary, and `defaultConfig`.
6
+ * - `normalize.ts` — untrusted JSON → `PolishedTuiConfig` normalization.
7
+ * - `file-io.ts` — atomic on-disk read/write of the config file.
8
+ * - `persist.ts` — `save*Patch` helpers used by the settings UI/commands.
9
+ *
10
+ * This file re-exports the full public API unchanged so existing imports of
11
+ * `../config/config.js` keep working without modification.
12
+ */
13
+ export type {
14
+ ColorSourcesConfig,
15
+ ColorSource,
16
+ ColorSpec,
17
+ ContextStyle,
18
+ ContextThresholds,
19
+ ExtensionStatusColorMode,
20
+ ExtensionStatusesConfig,
21
+ ExtensionStatusPlacement,
22
+ FooterSegmentsConfig,
23
+ GitBranchConfig,
24
+ GitBranchMaxLength,
25
+ GitCommitConfig,
26
+ GitMetricsConfig,
27
+ IconMode,
28
+ PathDisplayConfig,
29
+ PathDisplayMode,
30
+ PolishedTuiConfig,
31
+ SeparatorStyle,
32
+ UiFeaturesConfig,
33
+ } from "./schema.js";
34
+ export {
35
+ configPath,
36
+ defaultConfig,
37
+ FOOTER_FORMAT_ALIASES,
38
+ FOOTER_FORMAT_VARIABLES,
39
+ } from "./schema.js";
40
+
41
+ export {
42
+ getExtensionStatusColorMode,
43
+ getExtensionStatusPlacement,
44
+ isExtensionStatusColorMode,
45
+ isExtensionStatusPlacement,
46
+ isSeparatorStyle,
47
+ mergeConfig,
48
+ } from "./normalize.js";
49
+
50
+ export { ensureConfigExists, loadConfig } from "./file-io.js";
51
+
52
+ export {
53
+ saveColorSourcesPatch,
54
+ saveContextStylePatch,
55
+ saveContextThresholdsPatch,
56
+ saveExtensionStatusColorMode,
57
+ saveExtensionStatusPlacement,
58
+ saveFooterFormatPatch,
59
+ saveFooterSegmentsPatch,
60
+ saveGitBranchPatch,
61
+ saveIconsModePatch,
62
+ savePathDisplayPatch,
63
+ saveSeparatorPatch,
64
+ saveUiFeaturesPatch,
65
+ } from "./persist.js";
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Config-file persistence: atomic reads/writes of `~/.pi/rinco-pi-hud.json`.
3
+ *
4
+ * `mutateConfig` is the single choke point every `save*Patch` function in
5
+ * `persist.ts` goes through: it loads the current on-disk record (refusing
6
+ * to touch a corrupt file), applies a synchronous mutation, writes the
7
+ * result via a temp-file + fsync + rename (so a crash mid-write cannot
8
+ * leave a truncated/corrupt config on disk), and returns the freshly merged
9
+ * `PolishedTuiConfig`.
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import {
13
+ closeSync,
14
+ existsSync,
15
+ fchmodSync,
16
+ fsyncSync,
17
+ lstatSync,
18
+ openSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ renameSync,
22
+ statSync,
23
+ unlinkSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { basename, dirname, join } from "node:path";
27
+ import { isRecord, mergeConfig } from "./normalize.js";
28
+ import { configPath, type PolishedTuiConfig } from "./schema.js";
29
+
30
+ export type ConfigRecord = Record<string, unknown>;
31
+
32
+ type ConfigFileState =
33
+ | { kind: "missing"; record: ConfigRecord; writePath: string }
34
+ | { kind: "valid"; record: ConfigRecord; writePath: string; mode: number }
35
+ | { kind: "corrupt"; error: unknown };
36
+
37
+ function errorCode(error: unknown): string | undefined {
38
+ return typeof error === "object" && error !== null && "code" in error
39
+ ? String(error.code)
40
+ : undefined;
41
+ }
42
+
43
+ function readConfigFileState(path: string): ConfigFileState {
44
+ let writePath = path;
45
+ try {
46
+ const pathStat = lstatSync(path);
47
+ if (pathStat.isSymbolicLink()) writePath = realpathSync(path);
48
+ const targetStat = statSync(writePath);
49
+ const parsed = JSON.parse(readFileSync(writePath, "utf8"));
50
+ return isRecord(parsed)
51
+ ? { kind: "valid", record: parsed, writePath, mode: targetStat.mode & 0o7777 }
52
+ : { kind: "corrupt", error: new Error("top-level value must be a JSON object") };
53
+ } catch (error) {
54
+ if (errorCode(error) === "ENOENT") {
55
+ try {
56
+ lstatSync(path);
57
+ } catch (pathError) {
58
+ if (errorCode(pathError) === "ENOENT")
59
+ return { kind: "missing", record: {}, writePath: path };
60
+ }
61
+ }
62
+ return { kind: "corrupt", error };
63
+ }
64
+ }
65
+
66
+ function writeConfigAtomically(path: string, record: ConfigRecord, mode?: number): void {
67
+ const tempPath = join(dirname(path), `.${basename(path)}.${process.pid}.${randomUUID()}.tmp`);
68
+ let file: number | undefined;
69
+ try {
70
+ file = openSync(tempPath, "wx", mode ?? 0o666);
71
+ if (mode !== undefined) fchmodSync(file, mode);
72
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, "utf8");
73
+ fsyncSync(file);
74
+ closeSync(file);
75
+ file = undefined;
76
+ renameSync(tempPath, path);
77
+ } catch (error) {
78
+ if (file !== undefined) {
79
+ try {
80
+ closeSync(file);
81
+ } catch {}
82
+ }
83
+ try {
84
+ unlinkSync(tempPath);
85
+ } catch (cleanupError) {
86
+ if (errorCode(cleanupError) !== "ENOENT") {
87
+ // Preserve the persistence failure; the best-effort cleanup error is secondary.
88
+ }
89
+ }
90
+ throw error;
91
+ }
92
+ }
93
+
94
+ export function mutateConfig(
95
+ path: string,
96
+ mutate: (record: ConfigRecord) => void,
97
+ ): PolishedTuiConfig {
98
+ const state = readConfigFileState(path);
99
+ if (state.kind === "corrupt") {
100
+ const detail = state.error instanceof Error ? ` (${state.error.message})` : "";
101
+ throw new Error(
102
+ `Refusing to save Zentui config because ${path} is corrupt or unreadable; fix or remove it first.${detail}`,
103
+ );
104
+ }
105
+ mutate(state.record);
106
+ writeConfigAtomically(
107
+ state.writePath,
108
+ state.record,
109
+ state.kind === "valid" ? state.mode : undefined,
110
+ );
111
+ return mergeConfig(state.record);
112
+ }
113
+
114
+ export function ensureConfigExists(): void {
115
+ // Intentionally left as a no-op. Zentui config is user-owned and
116
+ // compatibility-sensitive: runtime defaults come from `mergeConfig({})`, and
117
+ // the extension should not persist opinionated defaults unless the user
118
+ // explicitly changes a setting.
119
+ }
120
+
121
+ export function loadConfig(): PolishedTuiConfig {
122
+ try {
123
+ if (!existsSync(configPath)) return mergeConfig({});
124
+ return mergeConfig(JSON.parse(readFileSync(configPath, "utf8")));
125
+ } catch {
126
+ return mergeConfig({});
127
+ }
128
+ }
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Untrusted-JSON → `PolishedTuiConfig` normalization.
3
+ *
4
+ * Every field is parsed defensively: unknown/malformed values fall back to
5
+ * `defaultConfig`, and free-form text (footer format, icon glyph overrides)
6
+ * is length-capped and stripped of control/VT characters before being stored.
7
+ */
8
+ import { stripVTControlCharacters } from "node:util";
9
+ import {
10
+ ICON_GLYPH_KEYS,
11
+ type IconGlyphs,
12
+ normalizeIconMode,
13
+ resolveConfiguredIcons,
14
+ } from "../ui/icons.js";
15
+ import { isSupportedColorSpec } from "../ui/style.js";
16
+ import {
17
+ DEFAULT_EXTENSION_STATUS_COLOR_MODE,
18
+ DEFAULT_PROJECT_REFRESH_INTERVAL_MS,
19
+ MIN_PROJECT_REFRESH_INTERVAL_MS,
20
+ type ColorSource,
21
+ type ColorSourcesConfig,
22
+ type ContextStyle,
23
+ type ContextThresholds,
24
+ defaultConfig,
25
+ type ExtensionStatusColorMode,
26
+ type ExtensionStatusesConfig,
27
+ type ExtensionStatusPlacement,
28
+ type FooterSegmentsConfig,
29
+ type GitBranchConfig,
30
+ type GitBranchMaxLength,
31
+ type GitCommitConfig,
32
+ type GitMetricsConfig,
33
+ type PathDisplayConfig,
34
+ type PolishedTuiConfig,
35
+ type SeparatorStyle,
36
+ type UiFeaturesConfig,
37
+ } from "./schema.js";
38
+
39
+ export type ConfigRecord = Record<string, unknown>;
40
+
41
+ export function isRecord(value: unknown): value is ConfigRecord {
42
+ return value !== null && typeof value === "object" && !Array.isArray(value);
43
+ }
44
+
45
+ function parseProjectRefreshIntervalMs(value: unknown): number {
46
+ if (value === 0) return 0;
47
+ if (typeof value !== "number" || !Number.isFinite(value)) {
48
+ return DEFAULT_PROJECT_REFRESH_INTERVAL_MS;
49
+ }
50
+
51
+ const interval = Math.round(value);
52
+ if (interval <= 0) return 0;
53
+ return Math.max(MIN_PROJECT_REFRESH_INTERVAL_MS, interval);
54
+ }
55
+
56
+ function clampPercent(value: number): number {
57
+ return Math.max(0, Math.min(100, value));
58
+ }
59
+
60
+ export function parseContextStyle(value: unknown): ContextStyle {
61
+ if (value === "text" || value === "gauge" || value === "text+gauge") return value;
62
+ return defaultConfig.contextStyle;
63
+ }
64
+
65
+ export function isSeparatorStyle(value: unknown): value is SeparatorStyle {
66
+ return value === "pipe" || value === "dot" || value === "chevron" || value === "none";
67
+ }
68
+
69
+ export function parseSeparatorStyle(value: unknown): SeparatorStyle {
70
+ return isSeparatorStyle(value) ? value : defaultConfig.separator;
71
+ }
72
+
73
+ function parseContextThresholds(value: unknown): ContextThresholds {
74
+ const defaults = defaultConfig.contextThresholds;
75
+ if (!isRecord(value)) return { ...defaults };
76
+
77
+ const warningRaw = value.warning;
78
+ const errorRaw = value.error;
79
+ let warning =
80
+ typeof warningRaw === "number" && Number.isFinite(warningRaw)
81
+ ? clampPercent(Math.round(warningRaw))
82
+ : defaults.warning;
83
+ let error =
84
+ typeof errorRaw === "number" && Number.isFinite(errorRaw)
85
+ ? clampPercent(Math.round(errorRaw))
86
+ : defaults.error;
87
+ if (error < warning) {
88
+ const swapped = warning;
89
+ warning = error;
90
+ error = swapped;
91
+ }
92
+ return { warning, error };
93
+ }
94
+
95
+ function parsePathDisplay(value: unknown): PathDisplayConfig {
96
+ const defaults = defaultConfig.pathDisplay;
97
+ if (!isRecord(value)) return { ...defaults };
98
+ const mode = value.mode === "full" || value.mode === "basename" ? value.mode : defaults.mode;
99
+ const rawDepth = value.depth;
100
+ const depth =
101
+ typeof rawDepth === "number" && Number.isFinite(rawDepth) && rawDepth >= 0
102
+ ? Math.min(5, Math.floor(rawDepth))
103
+ : defaults.depth;
104
+ return { mode, depth };
105
+ }
106
+
107
+ export function normalizeGitBranchMaxLength(value: unknown): GitBranchMaxLength {
108
+ if (value === "full") return value;
109
+ if (typeof value === "number" && Number.isInteger(value) && value > 0) return value;
110
+ return defaultConfig.gitBranch.maxLength;
111
+ }
112
+
113
+ function parseGitBranchConfig(value: unknown): GitBranchConfig {
114
+ const defaults = defaultConfig.gitBranch;
115
+ if (!isRecord(value)) return { ...defaults };
116
+ return {
117
+ maxLength: normalizeGitBranchMaxLength(value.maxLength),
118
+ };
119
+ }
120
+
121
+ function stringValue(record: Record<string, unknown>, key: string): string | undefined {
122
+ const value = record[key];
123
+ return typeof value === "string" ? value : undefined;
124
+ }
125
+
126
+ export function sanitizeConfigText(value: string, maxLength: number): string {
127
+ return stripVTControlCharacters(value.slice(0, maxLength * 4))
128
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
129
+ .slice(0, maxLength);
130
+ }
131
+
132
+ function colorValue(record: Record<string, unknown>, key: string): string | undefined {
133
+ const value = stringValue(record, key);
134
+ return value !== undefined && isSupportedColorSpec(value) ? value : undefined;
135
+ }
136
+
137
+ function colorSourceValue(
138
+ record: Record<string, unknown>,
139
+ key: keyof ColorSourcesConfig,
140
+ ): ColorSource {
141
+ const value = record[key];
142
+ return value === "terminal" || value === "theme" ? value : defaultConfig.colorSources[key];
143
+ }
144
+
145
+ function booleanValue(record: Record<string, unknown>, key: keyof UiFeaturesConfig): boolean {
146
+ const value = record[key];
147
+ return typeof value === "boolean" ? value : defaultConfig.features[key];
148
+ }
149
+
150
+ function footerSegmentValue(
151
+ record: Record<string, unknown>,
152
+ key: keyof FooterSegmentsConfig,
153
+ ): boolean {
154
+ const value = record[key];
155
+ return typeof value === "boolean" ? value : defaultConfig.footerSegments[key];
156
+ }
157
+
158
+ function definedColors(
159
+ colors: Partial<Record<keyof PolishedTuiConfig["colors"], string | undefined>>,
160
+ ): Partial<PolishedTuiConfig["colors"]> {
161
+ return Object.fromEntries(
162
+ Object.entries(colors).filter(
163
+ (entry): entry is [keyof PolishedTuiConfig["colors"], string] => typeof entry[1] === "string",
164
+ ),
165
+ ) as Partial<PolishedTuiConfig["colors"]>;
166
+ }
167
+
168
+ function normalizeIconOverrides(record: Record<string, unknown>): Partial<IconGlyphs> {
169
+ return Object.fromEntries(
170
+ ICON_GLYPH_KEYS.flatMap((key) => {
171
+ const value = stringValue(record, key);
172
+ return value === undefined ? [] : [[key, sanitizeConfigText(value, 64)]];
173
+ }),
174
+ ) as Partial<IconGlyphs>;
175
+ }
176
+
177
+ function normalizeColors(record: Record<string, unknown>): Partial<PolishedTuiConfig["colors"]> {
178
+ return definedColors({
179
+ cwd: colorValue(record, "cwd") ?? colorValue(record, "cwdText"),
180
+ gitBranch: colorValue(record, "gitBranch") ?? colorValue(record, "git"),
181
+ gitStatus: colorValue(record, "gitStatus"),
182
+ contextNormal: colorValue(record, "contextNormal"),
183
+ contextWarning: colorValue(record, "contextWarning"),
184
+ contextError: colorValue(record, "contextError"),
185
+ tokens: colorValue(record, "tokens"),
186
+ cost: colorValue(record, "cost"),
187
+ separator: colorValue(record, "separator"),
188
+ runtimePrefix: colorValue(record, "runtimePrefix"),
189
+ extensionStatus: colorValue(record, "extensionStatus"),
190
+ sessionDuration: colorValue(record, "sessionDuration"),
191
+ packageVersion: colorValue(record, "packageVersion"),
192
+ gitCommit: colorValue(record, "gitCommit"),
193
+ gitMetricsAdded: colorValue(record, "gitMetricsAdded"),
194
+ gitMetricsDeleted: colorValue(record, "gitMetricsDeleted"),
195
+ username: colorValue(record, "username"),
196
+ time: colorValue(record, "time"),
197
+ os: colorValue(record, "os"),
198
+ muted: colorValue(record, "muted"),
199
+ });
200
+ }
201
+
202
+ function normalizeColorSources(record: Record<string, unknown>): ColorSourcesConfig {
203
+ return { starship: colorSourceValue(record, "starship") };
204
+ }
205
+
206
+ function normalizeUiFeatures(record: Record<string, unknown>): UiFeaturesConfig {
207
+ return { statusLine: booleanValue(record, "statusLine") };
208
+ }
209
+
210
+ function normalizeFooterSegments(record: Record<string, unknown>): FooterSegmentsConfig {
211
+ return {
212
+ cwd: footerSegmentValue(record, "cwd"),
213
+ gitBranch: footerSegmentValue(record, "gitBranch"),
214
+ gitStatus: footerSegmentValue(record, "gitStatus"),
215
+ gitCounts: footerSegmentValue(record, "gitCounts"),
216
+ runtime: footerSegmentValue(record, "runtime"),
217
+ context: footerSegmentValue(record, "context"),
218
+ tokens: footerSegmentValue(record, "tokens"),
219
+ cost: footerSegmentValue(record, "cost"),
220
+ sessionDuration: footerSegmentValue(record, "sessionDuration"),
221
+ username: footerSegmentValue(record, "username"),
222
+ time: footerSegmentValue(record, "time"),
223
+ os: footerSegmentValue(record, "os"),
224
+ packageVersion: footerSegmentValue(record, "packageVersion"),
225
+ gitCommit: footerSegmentValue(record, "gitCommit"),
226
+ gitMetrics: footerSegmentValue(record, "gitMetrics"),
227
+ sessionName: footerSegmentValue(record, "sessionName"),
228
+ model: footerSegmentValue(record, "model"),
229
+ thinking: footerSegmentValue(record, "thinking"),
230
+ turnCount: footerSegmentValue(record, "turnCount"),
231
+ cacheDetails: footerSegmentValue(record, "cacheDetails"),
232
+ codexUsage: footerSegmentValue(record, "codexUsage"),
233
+ configCounts: footerSegmentValue(record, "configCounts"),
234
+ skills: footerSegmentValue(record, "skills"),
235
+ mcp: footerSegmentValue(record, "mcp"),
236
+ toolActivity: footerSegmentValue(record, "toolActivity"),
237
+ agentActivity: footerSegmentValue(record, "agentActivity"),
238
+ };
239
+ }
240
+
241
+ /** Clamp hashLength to Git's valid abbreviation range [4, 40]. */
242
+ function normalizeGitHashLength(value: unknown): number {
243
+ const parsed = typeof value === "number" ? value : Number(value);
244
+ if (!Number.isFinite(parsed)) return defaultConfig.gitCommit.hashLength;
245
+ const rounded = Math.round(parsed);
246
+ return Math.min(40, Math.max(4, rounded));
247
+ }
248
+
249
+ function normalizeGitCommitConfig(record: Record<string, unknown>): GitCommitConfig {
250
+ return {
251
+ hashLength: normalizeGitHashLength(record.hashLength),
252
+ onlyDetached:
253
+ typeof record.onlyDetached === "boolean"
254
+ ? record.onlyDetached
255
+ : defaultConfig.gitCommit.onlyDetached,
256
+ showTag: typeof record.showTag === "boolean" ? record.showTag : defaultConfig.gitCommit.showTag,
257
+ };
258
+ }
259
+
260
+ function normalizeGitMetricsConfig(record: Record<string, unknown>): GitMetricsConfig {
261
+ return {
262
+ onlyNonzero:
263
+ typeof record.onlyNonzero === "boolean"
264
+ ? record.onlyNonzero
265
+ : defaultConfig.gitMetrics.onlyNonzero,
266
+ ignoreSubmodules:
267
+ typeof record.ignoreSubmodules === "boolean"
268
+ ? record.ignoreSubmodules
269
+ : defaultConfig.gitMetrics.ignoreSubmodules,
270
+ };
271
+ }
272
+
273
+ export function isExtensionStatusPlacement(value: unknown): value is ExtensionStatusPlacement {
274
+ return value === "off" || value === "left" || value === "middle" || value === "right";
275
+ }
276
+
277
+ export function isExtensionStatusColorMode(value: unknown): value is ExtensionStatusColorMode {
278
+ return value === "zentui" || value === "original";
279
+ }
280
+
281
+ function normalizeExtensionStatuses(record: Record<string, unknown>): ExtensionStatusesConfig {
282
+ const defaultPlacement = isExtensionStatusPlacement(record.defaultPlacement)
283
+ ? record.defaultPlacement
284
+ : defaultConfig.extensionStatuses.defaultPlacement;
285
+ const placements = isRecord(record.placements)
286
+ ? Object.fromEntries(
287
+ Object.entries(record.placements).filter(
288
+ (entry): entry is [string, ExtensionStatusPlacement] =>
289
+ isExtensionStatusPlacement(entry[1]),
290
+ ),
291
+ )
292
+ : {};
293
+ const colorModes = isRecord(record.colorModes)
294
+ ? Object.fromEntries(
295
+ Object.entries(record.colorModes).filter(
296
+ (entry): entry is [string, ExtensionStatusColorMode] =>
297
+ isExtensionStatusColorMode(entry[1]),
298
+ ),
299
+ )
300
+ : {};
301
+
302
+ return {
303
+ defaultPlacement,
304
+ placements,
305
+ colorModes,
306
+ };
307
+ }
308
+
309
+ export function mergeConfig(parsed: unknown): PolishedTuiConfig {
310
+ const config = isRecord(parsed) ? parsed : {};
311
+ const iconsRecord = isRecord(config.icons) ? (config.icons as Record<string, unknown>) : {};
312
+ const iconMode = normalizeIconMode(iconsRecord.mode);
313
+ const iconOverrides = normalizeIconOverrides(iconsRecord);
314
+ const colors = isRecord(config.colors)
315
+ ? normalizeColors(config.colors as Record<string, unknown>)
316
+ : {};
317
+ const colorSources = isRecord(config.colorSources)
318
+ ? normalizeColorSources(config.colorSources as Record<string, unknown>)
319
+ : defaultConfig.colorSources;
320
+ const features = isRecord(config.features)
321
+ ? normalizeUiFeatures(config.features as Record<string, unknown>)
322
+ : defaultConfig.features;
323
+ const footerSegments = isRecord(config.footerSegments)
324
+ ? normalizeFooterSegments(config.footerSegments as Record<string, unknown>)
325
+ : defaultConfig.footerSegments;
326
+ const extensionStatuses = isRecord(config.extensionStatuses)
327
+ ? normalizeExtensionStatuses(config.extensionStatuses as Record<string, unknown>)
328
+ : defaultConfig.extensionStatuses;
329
+ const gitCommit = isRecord(config.gitCommit)
330
+ ? normalizeGitCommitConfig(config.gitCommit as Record<string, unknown>)
331
+ : defaultConfig.gitCommit;
332
+ const gitMetrics = isRecord(config.gitMetrics)
333
+ ? normalizeGitMetricsConfig(config.gitMetrics as Record<string, unknown>)
334
+ : defaultConfig.gitMetrics;
335
+ const gitBranch = parseGitBranchConfig(config.gitBranch);
336
+ return {
337
+ projectRefreshIntervalMs: parseProjectRefreshIntervalMs(config.projectRefreshIntervalMs),
338
+ footerFormat: sanitizeConfigText(stringValue(config, "footerFormat") ?? "", 4096),
339
+ separator: parseSeparatorStyle(config.separator),
340
+ contextStyle: parseContextStyle(config.contextStyle),
341
+ contextThresholds: parseContextThresholds(config.contextThresholds),
342
+ pathDisplay: parsePathDisplay(config.pathDisplay),
343
+ gitBranch,
344
+ icons: resolveConfiguredIcons(iconMode, iconOverrides),
345
+ colors: {
346
+ ...defaultConfig.colors,
347
+ ...colors,
348
+ },
349
+ colorSources: { ...colorSources },
350
+ features: { ...features },
351
+ footerSegments: { ...footerSegments },
352
+ gitCommit,
353
+ gitMetrics,
354
+ extensionStatuses: {
355
+ defaultPlacement: extensionStatuses.defaultPlacement,
356
+ placements: { ...extensionStatuses.placements },
357
+ colorModes: { ...extensionStatuses.colorModes },
358
+ },
359
+ };
360
+ }
361
+
362
+ export function getExtensionStatusPlacement(
363
+ config: PolishedTuiConfig,
364
+ key: string,
365
+ ): ExtensionStatusPlacement {
366
+ return config.extensionStatuses.placements[key] ?? config.extensionStatuses.defaultPlacement;
367
+ }
368
+
369
+ export function getExtensionStatusColorMode(
370
+ config: PolishedTuiConfig,
371
+ key: string,
372
+ ): ExtensionStatusColorMode {
373
+ return config.extensionStatuses.colorModes[key] ?? DEFAULT_EXTENSION_STATUS_COLOR_MODE;
374
+ }