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,765 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getSettingsListTheme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type AutocompleteItem,
5
+ Key,
6
+ matchesKey,
7
+ type SettingItem,
8
+ SettingsList,
9
+ type SettingsListTheme,
10
+ truncateToWidth,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ type ColorSource,
14
+ type ColorSourcesConfig,
15
+ type ContextStyle,
16
+ type ExtensionStatusColorMode,
17
+ type ExtensionStatusPlacement,
18
+ type FooterSegmentsConfig,
19
+ type GitBranchConfig,
20
+ type GitBranchMaxLength,
21
+ getExtensionStatusColorMode,
22
+ getExtensionStatusPlacement,
23
+ type IconMode,
24
+ isExtensionStatusColorMode,
25
+ isExtensionStatusPlacement,
26
+ isSeparatorStyle,
27
+ type PathDisplayConfig,
28
+ type PathDisplayMode,
29
+ type PolishedTuiConfig,
30
+ type SeparatorStyle,
31
+ type UiFeaturesConfig,
32
+ } from "../config/config.js";
33
+ import { sanitizeExtensionStatusText } from "../segments/extension-status.js";
34
+ import { isIconMode } from "../ui/icons.js";
35
+ import { safeThemeFg } from "../ui/style.js";
36
+
37
+ const colorSourceValues: ColorSource[] = ["theme", "terminal"];
38
+ const extensionStatusPlacementValues: ExtensionStatusPlacement[] = [
39
+ "off",
40
+ "left",
41
+ "middle",
42
+ "right",
43
+ ];
44
+ const extensionStatusColorModeValues: ExtensionStatusColorMode[] = ["zentui", "original"];
45
+ const contextStyleValues: ContextStyle[] = ["text", "gauge", "text+gauge"];
46
+ const separatorStyleValues: SeparatorStyle[] = ["pipe", "dot", "chevron", "none"];
47
+ const pathDisplayModeValues: PathDisplayMode[] = ["basename", "full"];
48
+ const pathDepthValues = ["0", "1", "2", "3", "4", "5"] as const;
49
+ const branchLengthPresetValues = ["full", "10", "20", "30", "40", "50"] as const;
50
+ const iconModeValues: IconMode[] = ["auto", "nerd", "ascii"];
51
+ type FeatureState = "enabled" | "disabled";
52
+
53
+ const featureStateValues: FeatureState[] = ["enabled", "disabled"];
54
+ const settingsSections = [
55
+ "coloring",
56
+ "features",
57
+ "layout",
58
+ "builtinSegments",
59
+ "extensionSegments",
60
+ ] as const;
61
+
62
+ type ColorSettingId = "starship";
63
+ type FeatureSettingId = keyof UiFeaturesConfig;
64
+ type FooterSegmentSettingId = keyof FooterSegmentsConfig;
65
+ type SettingsSection = (typeof settingsSections)[number];
66
+ type LayoutSettingId =
67
+ | "contextStyle"
68
+ | "separator"
69
+ | "pathDisplay"
70
+ | "pathDepth"
71
+ | "branchLength"
72
+ | "iconMode";
73
+
74
+ type SettingsCommandDeps = {
75
+ getConfig: () => PolishedTuiConfig;
76
+ setColorSources: (patch: Partial<ColorSourcesConfig>) => void;
77
+ setUiFeatures: (
78
+ patch: Partial<UiFeaturesConfig>,
79
+ ctx: ExtensionContext,
80
+ ) => void;
81
+ setFooterSegments: (patch: Partial<FooterSegmentsConfig>) => void;
82
+ setFooterFormat: (value: string) => void;
83
+ setIconMode: (mode: IconMode) => void;
84
+ setContextStyle: (style: ContextStyle) => void;
85
+ setSeparator: (separator: SeparatorStyle) => void;
86
+ setPathDisplay: (patch: Partial<PathDisplayConfig>) => void;
87
+ setGitBranch: (patch: Partial<GitBranchConfig>) => void;
88
+ getActiveExtensionStatuses: () => ReadonlyMap<string, string>;
89
+ setExtensionStatusPlacement: (key: string, placement: ExtensionStatusPlacement) => void;
90
+ setExtensionStatusColorMode: (key: string, colorMode: ExtensionStatusColorMode) => void;
91
+ requestRender: () => void;
92
+ settingsListTheme?: SettingsListTheme;
93
+ };
94
+
95
+ const colorSettingLabels: Record<ColorSettingId, string> = {
96
+ starship: "Footer colors",
97
+ };
98
+
99
+ const colorSettingDescriptions: Record<ColorSettingId, string> = {
100
+ starship:
101
+ "Choose whether footer runtime/git/context colors use Pi theme tokens or terminal palette styles.",
102
+ };
103
+
104
+ const featureSettingLabels: Record<FeatureSettingId, string> = {
105
+ statusLine: "Status line",
106
+ };
107
+
108
+ const featureSettingDescriptions: Record<FeatureSettingId, string> = {
109
+ statusLine: "Enable or disable Zentui's custom footer/status line.",
110
+ };
111
+
112
+ const footerSegmentSettingLabels: Record<FooterSegmentSettingId, string> = {
113
+ cwd: "Current directory",
114
+ gitBranch: "Git branch",
115
+ gitStatus: "Git status",
116
+ gitCounts: "Git counts",
117
+ sessionDuration: "Session duration",
118
+ username: "Username@host",
119
+ time: "Current time",
120
+ os: "OS icon",
121
+ runtime: "Runtime",
122
+ context: "Context usage",
123
+ tokens: "Token counts",
124
+ cost: "Session cost",
125
+ packageVersion: "Package version",
126
+ gitCommit: "Git commit",
127
+ gitMetrics: "Git line metrics",
128
+ sessionName: "Session name",
129
+ model: "Model",
130
+ thinking: "Thinking level",
131
+ turnCount: "Turn count",
132
+ cacheDetails: "Cache details",
133
+ codexUsage: "Codex subscription",
134
+ configCounts: "Config counts",
135
+ skills: "Skill activity",
136
+ mcp: "MCP connections",
137
+ toolActivity: "Tool activity",
138
+ agentActivity: "Agent run activity",
139
+ };
140
+
141
+ const footerSegmentSettingDescriptions: Record<FooterSegmentSettingId, string> = {
142
+ cwd: "Show or hide the current working directory on the project row.",
143
+ gitBranch: "Show or hide the git branch name on the project row.",
144
+ gitStatus: "Show or hide git status icons and ahead/behind markers on the project row.",
145
+ gitCounts:
146
+ "Show numeric ahead/behind and stash counts (requires the Git status segment to be enabled).",
147
+ sessionDuration: "Show session running time on the usage row.",
148
+ username: "Show user@hostname on the project row.",
149
+ time: "Show the current time (HH:MM) on the usage row.",
150
+ os: "Show an operating-system icon on the project row.",
151
+ runtime: "Show or hide the detected runtime/language on the project row.",
152
+ context: "Show or hide context usage on the usage row.",
153
+ tokens: "Show or hide input/output token counts on the usage row.",
154
+ cost: "Show or hide session cost on the usage row.",
155
+ packageVersion:
156
+ "Show the project’s own manifest version (package.json, Cargo.toml, pyproject.toml, …) on the project row. Distinct from the installed toolchain version.",
157
+ gitCommit:
158
+ "Show the current commit hash and optional exact-match tag on the project row. Starship `git_commit`-style; default off.",
159
+ gitMetrics:
160
+ "Show aggregate added/deleted line counts (e.g. `+ 12 − 3`) on the project row via `git diff HEAD --numstat`; default off.",
161
+ sessionName: "Show the current Pi session name on the session row.",
162
+ model: "Show provider/model information on the session row.",
163
+ thinking: "Show the selected thinking level on the session row for reasoning-capable models.",
164
+ turnCount: "Show the current turn index on the session row.",
165
+ cacheDetails: "Show cumulative cache read/write token totals on the usage row.",
166
+ codexUsage: "Show OpenAI Codex subscription rate limits on the usage row when available.",
167
+ configCounts: "Show instruction-file and installed Pi package counts on the project row.",
168
+ skills: "Show active/available Pi skill counts on the session row.",
169
+ mcp: "Show parsed MCP connected/total server counts on the session row.",
170
+ toolActivity: "Show completed or running native tools on the session row.",
171
+ agentActivity: "Show active main agent runs on the session row (not subagent count).",
172
+ };
173
+
174
+ const directCommandSuggestions = [
175
+ "statusline enable",
176
+ "statusline disable",
177
+ "statusline toggle",
178
+ "format clear",
179
+ "format $cwd on $git_branch $fill $context",
180
+ "format $cwd( on $git_branch)($git_status)$fill($context)( | $cost)",
181
+ ];
182
+
183
+ const sectionLabels: Record<SettingsSection, string> = {
184
+ coloring: "Coloring",
185
+ features: "Features",
186
+ layout: "Layout",
187
+ builtinSegments: "Built-in segments",
188
+ extensionSegments: "Extension segments",
189
+ };
190
+
191
+ const thirdPartyStatusSettingPrefix = "thirdPartyStatus:";
192
+ const footerSegmentSettingPrefix = "footerSegment:";
193
+ type ThirdPartyStatusSettingKind = "placement" | "colorMode";
194
+
195
+ function isColorSource(value: string): value is ColorSource {
196
+ return value === "theme" || value === "terminal";
197
+ }
198
+
199
+ function isColorSettingId(value: string): value is ColorSettingId {
200
+ return value === "starship";
201
+ }
202
+
203
+ function isFeatureSettingId(value: string): value is FeatureSettingId {
204
+ return value === "statusLine";
205
+ }
206
+
207
+ function isFooterSegmentSettingId(value: string): value is FooterSegmentSettingId {
208
+ return (
209
+ value === "cwd" ||
210
+ value === "gitBranch" ||
211
+ value === "gitStatus" ||
212
+ value === "gitCounts" ||
213
+ value === "sessionDuration" ||
214
+ value === "runtime" ||
215
+ value === "context" ||
216
+ value === "tokens" ||
217
+ value === "cost" ||
218
+ value === "username" ||
219
+ value === "time" ||
220
+ value === "os" ||
221
+ value === "packageVersion" ||
222
+ value === "gitCommit" ||
223
+ value === "gitMetrics" ||
224
+ value === "sessionName" ||
225
+ value === "model" ||
226
+ value === "thinking" ||
227
+ value === "turnCount" ||
228
+ value === "cacheDetails" ||
229
+ value === "codexUsage" ||
230
+ value === "configCounts" ||
231
+ value === "skills" ||
232
+ value === "mcp" ||
233
+ value === "toolActivity" ||
234
+ value === "agentActivity"
235
+ );
236
+ }
237
+
238
+ function isFeatureState(value: string): value is FeatureState {
239
+ return value === "enabled" || value === "disabled";
240
+ }
241
+
242
+ function isContextStyle(value: string): value is ContextStyle {
243
+ return value === "text" || value === "gauge" || value === "text+gauge";
244
+ }
245
+
246
+ function isPathDisplayMode(value: string): value is PathDisplayMode {
247
+ return value === "basename" || value === "full";
248
+ }
249
+
250
+ function isPathDepthValue(value: string): boolean {
251
+ return (pathDepthValues as readonly string[]).includes(value);
252
+ }
253
+
254
+ function parseGitBranchLengthValue(value: string): GitBranchMaxLength | undefined {
255
+ if (value === "full") return value;
256
+ const parsed = Number(value);
257
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
258
+ }
259
+
260
+ function branchLengthValues(maxLength: GitBranchMaxLength): string[] {
261
+ const current = String(maxLength);
262
+ return (branchLengthPresetValues as readonly string[]).includes(current)
263
+ ? [...branchLengthPresetValues]
264
+ : [current, ...branchLengthPresetValues];
265
+ }
266
+
267
+ function isLayoutSettingId(value: string): value is LayoutSettingId {
268
+ return (
269
+ value === "contextStyle" ||
270
+ value === "separator" ||
271
+ value === "pathDisplay" ||
272
+ value === "pathDepth" ||
273
+ value === "branchLength" ||
274
+ value === "iconMode"
275
+ );
276
+ }
277
+
278
+ function patchForSetting(_id: ColorSettingId, value: ColorSource): Partial<ColorSourcesConfig> {
279
+ return { starship: value };
280
+ }
281
+
282
+ function featureValue(enabled: boolean): FeatureState {
283
+ return enabled ? "enabled" : "disabled";
284
+ }
285
+
286
+ function featurePatch(id: FeatureSettingId, value: FeatureState): Partial<UiFeaturesConfig> {
287
+ return { [id]: value === "enabled" } as Partial<UiFeaturesConfig>;
288
+ }
289
+
290
+ function footerSegmentSettingId(key: FooterSegmentSettingId): string {
291
+ return `${footerSegmentSettingPrefix}${key}`;
292
+ }
293
+
294
+ function footerSegmentSettingFromId(id: string): FooterSegmentSettingId | undefined {
295
+ if (!id.startsWith(footerSegmentSettingPrefix)) return undefined;
296
+ const key = id.slice(footerSegmentSettingPrefix.length);
297
+ return isFooterSegmentSettingId(key) ? key : undefined;
298
+ }
299
+
300
+ function footerSegmentPatch(
301
+ id: FooterSegmentSettingId,
302
+ value: FeatureState,
303
+ ): Partial<FooterSegmentsConfig> {
304
+ return { [id]: value === "enabled" } as Partial<FooterSegmentsConfig>;
305
+ }
306
+
307
+ function usageText(): string {
308
+ return 'Usage: /zentui statusline [enable|disable|toggle] or /zentui format "<template>"';
309
+ }
310
+
311
+ function parseDirectFeatureCommand(
312
+ args: string,
313
+ config: PolishedTuiConfig,
314
+ ): { feature: FeatureSettingId; enabled: boolean } | undefined {
315
+ const normalized = args.trim().toLowerCase().replaceAll(/[_-]+/g, " ");
316
+ if (!normalized) return undefined;
317
+
318
+ const words = normalized.split(/\s+/g).filter(Boolean);
319
+ const hasWord = (value: string) => words.includes(value);
320
+ const feature = hasWord("footer") || hasWord("statusline") || hasWord("status")
321
+ ? "statusLine"
322
+ : undefined;
323
+ const action = hasWord("toggle")
324
+ ? "toggle"
325
+ : hasWord("enable") || hasWord("enabled") || hasWord("on")
326
+ ? "enable"
327
+ : hasWord("disable") || hasWord("disabled") || hasWord("off")
328
+ ? "disable"
329
+ : undefined;
330
+
331
+ if (!feature || !action) return undefined;
332
+
333
+ return {
334
+ feature,
335
+ enabled: action === "toggle" ? !config.features[feature] : action === "enable",
336
+ };
337
+ }
338
+
339
+ function parseFormatCommand(args: string): { value: string | undefined } | undefined {
340
+ const trimmed = args.trim();
341
+ if (!trimmed.toLowerCase().startsWith("format")) return undefined;
342
+
343
+ const rest = trimmed.slice("format".length).trim();
344
+ if (!rest || rest.toLowerCase() === "clear") return { value: undefined };
345
+
346
+ const unquoted =
347
+ rest.startsWith('"') && rest.endsWith('"') && rest.length >= 2 ? rest.slice(1, -1) : rest;
348
+ return { value: unquoted };
349
+ }
350
+
351
+ function argumentCompletions(prefix: string): AutocompleteItem[] | null {
352
+ const trimmedPrefix = prefix.trimStart().toLowerCase();
353
+ const items = directCommandSuggestions.map((value) => ({ value, label: value }));
354
+ const matches = items.filter((item) => item.value.startsWith(trimmedPrefix));
355
+ return matches.length > 0 ? matches : null;
356
+ }
357
+
358
+ function thirdPartyStatusSettingId(key: string, kind: ThirdPartyStatusSettingKind): string {
359
+ return `${thirdPartyStatusSettingPrefix}${kind}:${key}`;
360
+ }
361
+
362
+ function thirdPartyStatusSettingFromId(
363
+ id: string,
364
+ ): { kind: ThirdPartyStatusSettingKind; key: string } | undefined {
365
+ if (!id.startsWith(thirdPartyStatusSettingPrefix)) return undefined;
366
+ const rest = id.slice(thirdPartyStatusSettingPrefix.length);
367
+ const separatorIndex = rest.indexOf(":");
368
+ if (separatorIndex < 0) return undefined;
369
+
370
+ const kind = rest.slice(0, separatorIndex);
371
+ if (kind !== "placement" && kind !== "colorMode") return undefined;
372
+
373
+ return { kind, key: rest.slice(separatorIndex + 1) };
374
+ }
375
+
376
+ function buildItems(
377
+ section: SettingsSection,
378
+ config: PolishedTuiConfig,
379
+ activeStatuses: ReadonlyMap<string, string>,
380
+ ): SettingItem[] {
381
+ if (section === "coloring") {
382
+ return (Object.keys(colorSettingLabels) as ColorSettingId[]).map((key) => ({
383
+ id: key,
384
+ label: colorSettingLabels[key],
385
+ description: colorSettingDescriptions[key],
386
+ currentValue: config.colorSources.starship,
387
+ values: colorSourceValues,
388
+ }));
389
+ }
390
+
391
+ if (section === "features") {
392
+ return (Object.keys(featureSettingLabels) as FeatureSettingId[]).map((key) => ({
393
+ id: key,
394
+ label: featureSettingLabels[key],
395
+ description: featureSettingDescriptions[key],
396
+ currentValue: featureValue(config.features[key]),
397
+ values: featureStateValues,
398
+ }));
399
+ }
400
+ if (section === "layout") {
401
+ return [
402
+ {
403
+ id: "contextStyle",
404
+ label: "Context style",
405
+ description: "Render context as text, a gauge bar, or both.",
406
+ currentValue: config.contextStyle,
407
+ values: contextStyleValues,
408
+ },
409
+ {
410
+ id: "separator",
411
+ label: "Separator",
412
+ description: "Choose the separator between default footer segments.",
413
+ currentValue: config.separator,
414
+ values: separatorStyleValues,
415
+ },
416
+ {
417
+ id: "pathDisplay",
418
+ label: "Path display",
419
+ description: "Show cwd as basename or full path (home contracted to ~).",
420
+ currentValue: config.pathDisplay.mode,
421
+ values: pathDisplayModeValues,
422
+ },
423
+ {
424
+ id: "pathDepth",
425
+ label: "Path depth",
426
+ description:
427
+ "In full mode, trailing directories to show (0 = all, max 5). Ignored for basename.",
428
+ currentValue: String(config.pathDisplay.depth),
429
+ values: [...pathDepthValues],
430
+ },
431
+ {
432
+ id: "branchLength",
433
+ label: "Branch length",
434
+ description: "Show the full branch name or truncate it to a preset visible width.",
435
+ currentValue: String(config.gitBranch.maxLength),
436
+ values: branchLengthValues(config.gitBranch.maxLength),
437
+ },
438
+ {
439
+ id: "iconMode",
440
+ label: "Icon mode",
441
+ description: "auto/nerd use Nerd Font glyphs; ascii uses plain fallbacks.",
442
+ currentValue: config.icons.mode,
443
+ values: iconModeValues,
444
+ },
445
+ ];
446
+ }
447
+
448
+ if (section === "builtinSegments") {
449
+ return (Object.keys(footerSegmentSettingLabels) as FooterSegmentSettingId[]).map((key) => ({
450
+ id: footerSegmentSettingId(key),
451
+ label: footerSegmentSettingLabels[key],
452
+ description: footerSegmentSettingDescriptions[key],
453
+ currentValue: featureValue(config.footerSegments[key]),
454
+ values: featureStateValues,
455
+ }));
456
+ }
457
+
458
+ const statuses = Array.from(activeStatuses.entries()).sort(([a], [b]) =>
459
+ a < b ? -1 : a > b ? 1 : 0,
460
+ );
461
+ if (statuses.length === 0) {
462
+ return [
463
+ {
464
+ id: "noThirdPartyStatuses",
465
+ label: "No active statuses",
466
+ description: "This tab only lists statuses currently published through ctx.ui.setStatus().",
467
+ currentValue: "—",
468
+ },
469
+ ];
470
+ }
471
+
472
+ return statuses.flatMap(([key, value]) => {
473
+ const sanitizedText = sanitizeExtensionStatusText(value);
474
+ const description = sanitizedText ? `Current status: ${sanitizedText}` : undefined;
475
+ return [
476
+ {
477
+ id: thirdPartyStatusSettingId(key, "placement"),
478
+ label: `${key} placement`,
479
+ description,
480
+ currentValue: getExtensionStatusPlacement(config, key),
481
+ values: extensionStatusPlacementValues,
482
+ },
483
+ {
484
+ id: thirdPartyStatusSettingId(key, "colorMode"),
485
+ label: `${key} color`,
486
+ description,
487
+ currentValue: getExtensionStatusColorMode(config, key),
488
+ values: extensionStatusColorModeValues,
489
+ },
490
+ ];
491
+ });
492
+ }
493
+
494
+ function nextSection(section: SettingsSection): SettingsSection {
495
+ const currentIndex = settingsSections.indexOf(section);
496
+ return settingsSections[(currentIndex + 1) % settingsSections.length] ?? "coloring";
497
+ }
498
+
499
+ function previousSection(section: SettingsSection): SettingsSection {
500
+ const currentIndex = settingsSections.indexOf(section);
501
+ return (
502
+ settingsSections[(currentIndex - 1 + settingsSections.length) % settingsSections.length] ??
503
+ "coloring"
504
+ );
505
+ }
506
+
507
+ function formatSectionTabs(
508
+ activeSection: SettingsSection,
509
+ theme: ExtensionContext["ui"]["theme"],
510
+ ): string {
511
+ const rendered = settingsSections.map((section) => {
512
+ const label = sectionLabels[section];
513
+ return section === activeSection ? theme.bold(label) : safeThemeFg(theme, "muted", label);
514
+ });
515
+ return ` ${rendered.join(safeThemeFg(theme, "muted", " / "))}`;
516
+ }
517
+
518
+ function withSectionFooter(lines: string[], theme: ExtensionContext["ui"]["theme"]): string[] {
519
+ const next = [...lines];
520
+ for (let index = next.length - 1; index >= 0; index -= 1) {
521
+ if (next[index]?.includes("Enter/Space")) {
522
+ next[index] = safeThemeFg(
523
+ theme,
524
+ "muted",
525
+ " Enter/Space to change · Tab/Shift+Tab to switch sections · Esc to close",
526
+ );
527
+ break;
528
+ }
529
+ }
530
+ return next;
531
+ }
532
+
533
+ export function registerZentuiSettingsCommand(pi: ExtensionAPI, deps: SettingsCommandDeps): void {
534
+ pi.registerCommand("zentui", {
535
+ description: "Configure Zentui",
536
+ getArgumentCompletions: argumentCompletions,
537
+ handler: async (_args, ctx) => {
538
+ const args = typeof _args === "string" ? _args : "";
539
+
540
+ const formatCommand = parseFormatCommand(args);
541
+ if (formatCommand) {
542
+ try {
543
+ deps.setFooterFormat(formatCommand.value ?? "");
544
+ deps.requestRender();
545
+ if (ctx.hasUI) {
546
+ if (formatCommand.value === undefined) {
547
+ ctx.ui.notify("Footer format cleared (using default layout)", "info");
548
+ } else {
549
+ ctx.ui.notify(`Footer format: ${formatCommand.value}`, "info");
550
+ }
551
+ }
552
+ } catch (error) {
553
+ const message = error instanceof Error ? error.message : String(error);
554
+ if (ctx.hasUI) ctx.ui.notify(`Could not update footer format: ${message}`, "error");
555
+ }
556
+ return;
557
+ }
558
+
559
+ const directCommand = parseDirectFeatureCommand(args, deps.getConfig());
560
+ if (directCommand) {
561
+ try {
562
+ deps.setUiFeatures({ [directCommand.feature]: directCommand.enabled }, ctx);
563
+ deps.requestRender();
564
+ if (ctx.hasUI) {
565
+ ctx.ui.notify(
566
+ `${featureSettingLabels[directCommand.feature]}: ${featureValue(directCommand.enabled)}`,
567
+ "info",
568
+ );
569
+ }
570
+ } catch (error) {
571
+ const message = error instanceof Error ? error.message : String(error);
572
+ if (ctx.hasUI) ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
573
+ }
574
+ return;
575
+ }
576
+
577
+ if (args.trim()) {
578
+ if (ctx.hasUI) ctx.ui.notify(usageText(), "warning");
579
+ return;
580
+ }
581
+
582
+ const mode = (ctx as typeof ctx & { mode?: string }).mode;
583
+ if (!ctx.hasUI || (mode !== undefined && mode !== "tui")) return;
584
+
585
+ await ctx.ui.custom<void>((tui, theme, _keybindings, done) => {
586
+ const settingsListTheme = deps.settingsListTheme ?? getSettingsListTheme();
587
+ let activeSection: SettingsSection = "coloring";
588
+ const applyFeatureChange = (id: FeatureSettingId, newValue: FeatureState) => {
589
+ deps.setUiFeatures(featurePatch(id, newValue), ctx);
590
+ deps.requestRender();
591
+ ctx.ui.notify(`${featureSettingLabels[id]}: ${newValue}`, "info");
592
+ tui.requestRender();
593
+ };
594
+ let settingsList: SettingsList;
595
+ const makeSettingsList = () =>
596
+ new SettingsList(
597
+ buildItems(activeSection, deps.getConfig(), deps.getActiveExtensionStatuses()),
598
+ 8,
599
+ settingsListTheme,
600
+ (id, newValue) => {
601
+ try {
602
+ if (isColorSettingId(id) && isColorSource(newValue)) {
603
+ deps.setColorSources(patchForSetting(id, newValue));
604
+ settingsList.updateValue(id, newValue);
605
+ deps.requestRender();
606
+ ctx.ui.notify(`${colorSettingLabels[id]}: ${newValue}`, "info");
607
+ tui.requestRender();
608
+ return;
609
+ }
610
+
611
+ if (isFeatureSettingId(id) && isFeatureState(newValue)) {
612
+ applyFeatureChange(id, newValue);
613
+ settingsList.updateValue(id, newValue);
614
+ return;
615
+ }
616
+
617
+ if (isLayoutSettingId(id)) {
618
+ if (id === "contextStyle" && isContextStyle(newValue)) {
619
+ deps.setContextStyle(newValue);
620
+ settingsList.updateValue(id, newValue);
621
+ deps.requestRender();
622
+ ctx.ui.notify(`Context style: ${newValue}`, "info");
623
+ tui.requestRender();
624
+ return;
625
+ }
626
+
627
+ if (id === "separator" && isSeparatorStyle(newValue)) {
628
+ deps.setSeparator(newValue);
629
+ settingsList.updateValue(id, newValue);
630
+ deps.requestRender();
631
+ ctx.ui.notify(`Separator: ${newValue}`, "info");
632
+ tui.requestRender();
633
+ return;
634
+ }
635
+
636
+ if (id === "pathDisplay" && isPathDisplayMode(newValue)) {
637
+ deps.setPathDisplay({ mode: newValue });
638
+ settingsList.updateValue(id, newValue);
639
+ deps.requestRender();
640
+ ctx.ui.notify(`Path display: ${newValue}`, "info");
641
+ tui.requestRender();
642
+ return;
643
+ }
644
+
645
+ if (id === "pathDepth" && isPathDepthValue(newValue)) {
646
+ deps.setPathDisplay({ depth: Number(newValue) });
647
+ settingsList.updateValue(id, newValue);
648
+ deps.requestRender();
649
+ ctx.ui.notify(`Path depth: ${newValue}`, "info");
650
+ tui.requestRender();
651
+ return;
652
+ }
653
+
654
+ if (id === "branchLength") {
655
+ const maxLength = parseGitBranchLengthValue(newValue);
656
+ if (maxLength === undefined) return;
657
+ deps.setGitBranch({ maxLength });
658
+ settingsList.updateValue(id, newValue);
659
+ deps.requestRender();
660
+ ctx.ui.notify(`Branch length: ${newValue}`, "info");
661
+ tui.requestRender();
662
+ return;
663
+ }
664
+
665
+ if (id === "iconMode" && isIconMode(newValue)) {
666
+ deps.setIconMode(newValue);
667
+ settingsList.updateValue(id, newValue);
668
+ deps.requestRender();
669
+ ctx.ui.notify(`Icon mode: ${newValue}`, "info");
670
+ tui.requestRender();
671
+ }
672
+ return;
673
+ }
674
+
675
+ const footerSegmentSetting = footerSegmentSettingFromId(id);
676
+ if (footerSegmentSetting && isFeatureState(newValue)) {
677
+ deps.setFooterSegments(footerSegmentPatch(footerSegmentSetting, newValue));
678
+ settingsList.updateValue(id, newValue);
679
+ deps.requestRender();
680
+ ctx.ui.notify(
681
+ `${footerSegmentSettingLabels[footerSegmentSetting]}: ${newValue}`,
682
+ "info",
683
+ );
684
+ tui.requestRender();
685
+ return;
686
+ }
687
+
688
+ const thirdPartyStatusSetting = thirdPartyStatusSettingFromId(id);
689
+ if (
690
+ thirdPartyStatusSetting?.kind === "placement" &&
691
+ isExtensionStatusPlacement(newValue)
692
+ ) {
693
+ deps.setExtensionStatusPlacement(thirdPartyStatusSetting.key, newValue);
694
+ settingsList.updateValue(id, newValue);
695
+ deps.requestRender();
696
+ ctx.ui.notify(
697
+ `Third-party status ${thirdPartyStatusSetting.key} placement: ${newValue}`,
698
+ "info",
699
+ );
700
+ tui.requestRender();
701
+ return;
702
+ }
703
+
704
+ if (
705
+ thirdPartyStatusSetting?.kind === "colorMode" &&
706
+ isExtensionStatusColorMode(newValue)
707
+ ) {
708
+ deps.setExtensionStatusColorMode(thirdPartyStatusSetting.key, newValue);
709
+ settingsList.updateValue(id, newValue);
710
+ deps.requestRender();
711
+ ctx.ui.notify(
712
+ `Third-party status ${thirdPartyStatusSetting.key} color: ${newValue}`,
713
+ "info",
714
+ );
715
+ tui.requestRender();
716
+ }
717
+ } catch (error) {
718
+ settingsList = makeSettingsList();
719
+ tui.requestRender();
720
+ const message = error instanceof Error ? error.message : String(error);
721
+ ctx.ui.notify(`Could not update Zentui settings: ${message}`, "error");
722
+ }
723
+ },
724
+ () => done(undefined),
725
+ );
726
+ settingsList = makeSettingsList();
727
+ const switchSection = (direction: "forward" | "backward") => {
728
+ activeSection =
729
+ direction === "forward" ? nextSection(activeSection) : previousSection(activeSection);
730
+ settingsList = makeSettingsList();
731
+ tui.requestRender();
732
+ };
733
+
734
+ return {
735
+ render(width: number) {
736
+ const border = safeThemeFg(theme, "borderMuted", "─".repeat(Math.max(0, width)));
737
+ return [
738
+ truncateToWidth(border, width, ""),
739
+ truncateToWidth(formatSectionTabs(activeSection, theme), width, ""),
740
+ truncateToWidth(border, width, ""),
741
+ ...withSectionFooter(settingsList.render(width), theme).map((line) =>
742
+ truncateToWidth(line, width, ""),
743
+ ),
744
+ truncateToWidth(border, width, ""),
745
+ ];
746
+ },
747
+ invalidate() {
748
+ settingsList.invalidate();
749
+ },
750
+ handleInput(data: string) {
751
+ if (matchesKey(data, Key.tab)) {
752
+ switchSection("forward");
753
+ return;
754
+ }
755
+ if (matchesKey(data, Key.shift("tab"))) {
756
+ switchSection("backward");
757
+ return;
758
+ }
759
+ settingsList.handleInput(data);
760
+ },
761
+ };
762
+ });
763
+ },
764
+ });
765
+ }