pi-cc-extensions 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.
Files changed (39) hide show
  1. package/README.md +141 -0
  2. package/assets/readme/context-preview.webp +0 -0
  3. package/assets/readme/context-usage.webp +0 -0
  4. package/assets/readme/hero.svg +101 -0
  5. package/assets/readme/session-reference.webp +0 -0
  6. package/assets/readme/showcase.webp +0 -0
  7. package/extensions/README.md +3 -0
  8. package/extensions/ccstyle-powerline/bash-mode/completion.ts +556 -0
  9. package/extensions/ccstyle-powerline/bash-mode/editor.ts +442 -0
  10. package/extensions/ccstyle-powerline/bash-mode/history.ts +151 -0
  11. package/extensions/ccstyle-powerline/bash-mode/shell-session.ts +286 -0
  12. package/extensions/ccstyle-powerline/bash-mode/transcript.ts +108 -0
  13. package/extensions/ccstyle-powerline/bash-mode/types.ts +59 -0
  14. package/extensions/ccstyle-powerline/colors.ts +69 -0
  15. package/extensions/ccstyle-powerline/context-usage.ts +46 -0
  16. package/extensions/ccstyle-powerline/fixed-editor/cluster.ts +107 -0
  17. package/extensions/ccstyle-powerline/fixed-editor/terminal-split.ts +1317 -0
  18. package/extensions/ccstyle-powerline/git-status.ts +218 -0
  19. package/extensions/ccstyle-powerline/icons.ts +181 -0
  20. package/extensions/ccstyle-powerline/powerline-config.ts +234 -0
  21. package/extensions/ccstyle-powerline/powerline.ts +3150 -0
  22. package/extensions/ccstyle-powerline/presets.ts +109 -0
  23. package/extensions/ccstyle-powerline/render-scheduler.ts +24 -0
  24. package/extensions/ccstyle-powerline/segments.ts +476 -0
  25. package/extensions/ccstyle-powerline/separators.ts +41 -0
  26. package/extensions/ccstyle-powerline/shortcuts.ts +57 -0
  27. package/extensions/ccstyle-powerline/theme.json +26 -0
  28. package/extensions/ccstyle-powerline/theme.ts +241 -0
  29. package/extensions/ccstyle-powerline/types.ts +197 -0
  30. package/extensions/ccstyle-powerline/welcome-dismiss.ts +34 -0
  31. package/extensions/ccstyle-powerline/welcome.ts +611 -0
  32. package/extensions/ccstyle-powerline/working-vibes.ts +695 -0
  33. package/extensions/claude-code-style.ts +987 -0
  34. package/extensions/context.ts +362 -0
  35. package/extensions/session-reference/README.md +28 -0
  36. package/extensions/session-reference/core.test.ts +74 -0
  37. package/extensions/session-reference/core.ts +258 -0
  38. package/extensions/session-reference/index.ts +379 -0
  39. package/package.json +33 -0
@@ -0,0 +1,218 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { GitStatus } from "./types.ts";
3
+
4
+ interface CachedGitStatus {
5
+ staged: number;
6
+ unstaged: number;
7
+ untracked: number;
8
+ timestamp: number;
9
+ }
10
+
11
+ interface CachedBranch {
12
+ branch: string | null;
13
+ timestamp: number;
14
+ }
15
+
16
+ export type GitPollingMode = "full" | "branch" | "off";
17
+
18
+ const CACHE_TTL_MS = 1000; // 1 second for file status
19
+ const BRANCH_TTL_MS = 500; // Shorter TTL so branch updates quickly after invalidation
20
+ let cachedStatus: CachedGitStatus | null = null;
21
+ let cachedBranch: CachedBranch | null = null;
22
+ let pendingFetch: Promise<void> | null = null;
23
+ let pendingBranchFetch: Promise<void> | null = null;
24
+ let invalidationCounter = 0; // Track invalidations to prevent stale updates
25
+ let branchInvalidationCounter = 0;
26
+
27
+ /**
28
+ * Parse git status --porcelain output
29
+ *
30
+ * Format: XY filename
31
+ * X = index status, Y = working tree status
32
+ * ?? = untracked
33
+ * Other X values = staged
34
+ * Other Y values = unstaged
35
+ */
36
+ function parseGitStatusOutput(output: string): { staged: number; unstaged: number; untracked: number } {
37
+ let staged = 0;
38
+ let unstaged = 0;
39
+ let untracked = 0;
40
+
41
+ for (const line of output.split("\n")) {
42
+ if (!line) continue;
43
+ const x = line[0];
44
+ const y = line[1];
45
+
46
+ if (x === "?" && y === "?") {
47
+ untracked++;
48
+ continue;
49
+ }
50
+
51
+ // X position (index/staged)
52
+ if (x && x !== " " && x !== "?") {
53
+ staged++;
54
+ }
55
+
56
+ // Y position (working tree/unstaged)
57
+ if (y && y !== " ") {
58
+ unstaged++;
59
+ }
60
+ }
61
+
62
+ return { staged, unstaged, untracked };
63
+ }
64
+
65
+ function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
66
+ return new Promise((resolve) => {
67
+ const proc = spawn("git", args, {
68
+ stdio: ["ignore", "pipe", "pipe"],
69
+ });
70
+
71
+ let stdout = "";
72
+ let resolved = false;
73
+
74
+ const finish = (result: string | null) => {
75
+ if (resolved) return;
76
+ resolved = true;
77
+ clearTimeout(timeoutId);
78
+ resolve(result);
79
+ };
80
+
81
+ proc.stdout.on("data", (data) => {
82
+ stdout += data.toString();
83
+ });
84
+
85
+ proc.on("close", (code) => {
86
+ finish(code === 0 ? stdout.trim() : null);
87
+ });
88
+
89
+ proc.on("error", () => {
90
+ finish(null);
91
+ });
92
+
93
+ const timeoutId = setTimeout(() => {
94
+ proc.kill();
95
+ finish(null);
96
+ }, timeoutMs);
97
+ });
98
+ }
99
+
100
+ /**
101
+ * Fetch current git branch asynchronously.
102
+ * For detached HEAD, returns the short commit SHA (matches provider's "detached" behavior).
103
+ */
104
+ async function fetchGitBranch(): Promise<string | null> {
105
+ const branch = await runGit(["branch", "--show-current"]);
106
+ if (branch === null) return null;
107
+ if (branch) return branch;
108
+
109
+ const sha = await runGit(["rev-parse", "--short", "HEAD"]);
110
+ return sha ? `${sha} (detached)` : "detached";
111
+ }
112
+
113
+ /**
114
+ * Fetch git status asynchronously
115
+ */
116
+ async function fetchGitStatus(): Promise<{ staged: number; unstaged: number; untracked: number } | null> {
117
+ const output = await runGit(["status", "--porcelain"], 500);
118
+ if (output === null) return null;
119
+ return parseGitStatusOutput(output);
120
+ }
121
+
122
+ /**
123
+ * Get the current git branch with caching.
124
+ * Falls back to provider branch if our cache is empty.
125
+ */
126
+ export function getCurrentBranch(providerBranch: string | null): string | null {
127
+ const now = Date.now();
128
+
129
+ // Return cached if fresh
130
+ if (cachedBranch && now - cachedBranch.timestamp < BRANCH_TTL_MS) {
131
+ return cachedBranch.branch;
132
+ }
133
+
134
+ // Trigger background fetch if not already pending
135
+ if (!pendingBranchFetch) {
136
+ const fetchId = branchInvalidationCounter;
137
+ pendingBranchFetch = fetchGitBranch().then((result) => {
138
+ // Cache result if no invalidation happened (including null for non-git dirs)
139
+ if (fetchId === branchInvalidationCounter) {
140
+ cachedBranch = {
141
+ branch: result,
142
+ timestamp: Date.now(),
143
+ };
144
+ }
145
+ pendingBranchFetch = null;
146
+ });
147
+ }
148
+
149
+ // Return stale cache while refreshing; only use provider before first fetch
150
+ return cachedBranch ? cachedBranch.branch : providerBranch;
151
+ }
152
+
153
+ /**
154
+ * Get git status with caching.
155
+ * Returns cached value if within TTL, otherwise triggers async fetch.
156
+ * This is designed for synchronous render() calls - returns last known value
157
+ * while refreshing in background.
158
+ */
159
+ export function getGitStatus(providerBranch: string | null, pollingMode: GitPollingMode = "full"): GitStatus {
160
+ const now = Date.now();
161
+ const branch = pollingMode === "off" ? providerBranch : getCurrentBranch(providerBranch);
162
+
163
+ if (pollingMode !== "full") {
164
+ return { branch, staged: 0, unstaged: 0, untracked: 0 };
165
+ }
166
+
167
+ // Return cached if fresh
168
+ if (cachedStatus && now - cachedStatus.timestamp < CACHE_TTL_MS) {
169
+ return {
170
+ branch,
171
+ staged: cachedStatus.staged,
172
+ unstaged: cachedStatus.unstaged,
173
+ untracked: cachedStatus.untracked,
174
+ };
175
+ }
176
+
177
+ // Trigger background fetch if not already pending
178
+ if (!pendingFetch) {
179
+ const fetchId = invalidationCounter; // Capture current counter
180
+ pendingFetch = fetchGitStatus().then((result) => {
181
+ // Cache result if no invalidation happened (including null for non-git dirs)
182
+ if (fetchId === invalidationCounter) {
183
+ cachedStatus = result
184
+ ? { staged: result.staged, unstaged: result.unstaged, untracked: result.untracked, timestamp: Date.now() }
185
+ : { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
186
+ }
187
+ pendingFetch = null;
188
+ });
189
+ }
190
+
191
+ // Return last cached or empty
192
+ if (cachedStatus) {
193
+ return {
194
+ branch,
195
+ staged: cachedStatus.staged,
196
+ unstaged: cachedStatus.unstaged,
197
+ untracked: cachedStatus.untracked,
198
+ };
199
+ }
200
+
201
+ return { branch, staged: 0, unstaged: 0, untracked: 0 };
202
+ }
203
+
204
+ /**
205
+ * Force refresh git status (call when you know files changed)
206
+ */
207
+ export function invalidateGitStatus(): void {
208
+ cachedStatus = null;
209
+ invalidationCounter++; // Increment to invalidate any pending fetches
210
+ }
211
+
212
+ /**
213
+ * Force refresh git branch (call when you know branch might have changed)
214
+ */
215
+ export function invalidateGitBranch(): void {
216
+ cachedBranch = null;
217
+ branchInvalidationCounter++;
218
+ }
@@ -0,0 +1,181 @@
1
+ import { loadThemeConfig } from "./theme.ts";
2
+
3
+ export interface IconSet {
4
+ pi: string;
5
+ model: string;
6
+ folder: string;
7
+ branch: string;
8
+ git: string;
9
+ tokens: string;
10
+ context: string;
11
+ cost: string;
12
+ time: string;
13
+ agents: string;
14
+ cache: string;
15
+ input: string;
16
+ output: string;
17
+ host: string;
18
+ session: string;
19
+ auto: string;
20
+ warning: string;
21
+ }
22
+
23
+ // Separator characters
24
+ export const SEP_DOT = " · ";
25
+
26
+ // Thinking level display text (Unicode/ASCII)
27
+ export const THINKING_TEXT_UNICODE: Record<string, string> = {
28
+ minimal: "[min]",
29
+ low: "[low]",
30
+ medium: "[med]",
31
+ high: "[high]",
32
+ xhigh: "[xhi]",
33
+ max: "[max]",
34
+ };
35
+
36
+ // Thinking level display text (Nerd Fonts - with icons)
37
+ export const THINKING_TEXT_NERD: Record<string, string> = {
38
+ minimal: "\u{F0E7} min", // lightning bolt
39
+ low: "\u{F10C} low", // circle outline
40
+ medium: "\u{F192} med", // dot circle
41
+ high: "\u{F111} high", // circle
42
+ xhigh: "\u{F06D} xhi", // fire
43
+ max: "\u{F06D} max", // fire (maximum reasoning)
44
+ };
45
+
46
+ // Get thinking text based on font support
47
+ export function getThinkingText(level: string): string | undefined {
48
+ if (hasNerdFonts()) {
49
+ return THINKING_TEXT_NERD[level];
50
+ }
51
+ return THINKING_TEXT_UNICODE[level];
52
+ }
53
+
54
+ // Nerd Font icons (matching oh-my-pi exactly)
55
+ export const NERD_ICONS: IconSet = {
56
+ pi: "\uE22C", // nf-oct-pi (stylized pi icon)
57
+ model: "\uEC19", // nf-md-chip (model/AI chip)
58
+ folder: "\uF115", // nf-fa-folder_open
59
+ branch: "\uF126", // nf-fa-code_fork (git branch)
60
+ git: "\uF1D3", // nf-fa-git (git logo)
61
+ tokens: "\uE26B", // nf-seti-html (tokens symbol)
62
+ context: "\uE70F", // nf-dev-database (database)
63
+ cost: "\uF155", // nf-fa-dollar
64
+ time: "\uF017", // nf-fa-clock_o
65
+ agents: "\uF0C0", // nf-fa-users
66
+ cache: "\uF1C0", // nf-fa-database (cache)
67
+ input: "\uF090", // nf-fa-sign_in (input arrow)
68
+ output: "\uF08B", // nf-fa-sign_out (output arrow)
69
+ host: "\uF109", // nf-fa-laptop (host)
70
+ session: "\uF550", // nf-md-identifier (session id)
71
+ auto: "\u{F0068}", // nf-md-lightning_bolt (auto-compact)
72
+ warning: "\uF071", // nf-fa-warning
73
+ };
74
+
75
+ // ASCII/Unicode fallback icons — pure ASCII only, no obscure Unicode that may render as □
76
+ export const ASCII_ICONS: IconSet = {
77
+ pi: "pi",
78
+ model: "",
79
+ folder: "dir",
80
+ branch: "@",
81
+ git: "@",
82
+ tokens: "~",
83
+ context: "ctx",
84
+ cost: "$",
85
+ time: "~",
86
+ agents: "AG",
87
+ cache: "cache",
88
+ input: "in:",
89
+ output: "out:",
90
+ host: "host",
91
+ session: "id",
92
+ auto: "AC",
93
+ warning: "!",
94
+ };
95
+
96
+ type PartialIconSet = Partial<IconSet>;
97
+
98
+ function sanitizeUserIconOverrides(value: unknown): PartialIconSet {
99
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
100
+ return {};
101
+ }
102
+
103
+ const sanitized: PartialIconSet = {};
104
+ const validKeys = Object.keys(NERD_ICONS) as Array<keyof IconSet>;
105
+ for (const key of validKeys) {
106
+ const icon = value[key];
107
+ if (typeof icon === "string") {
108
+ sanitized[key] = icon;
109
+ }
110
+ }
111
+
112
+ return sanitized;
113
+ }
114
+
115
+ // Separator characters
116
+ export interface SeparatorChars {
117
+ powerlineLeft: string;
118
+ powerlineRight: string;
119
+ powerlineThinLeft: string;
120
+ powerlineThinRight: string;
121
+ slash: string;
122
+ pipe: string;
123
+ block: string;
124
+ space: string;
125
+ asciiLeft: string;
126
+ asciiRight: string;
127
+ dot: string;
128
+ }
129
+
130
+ export const NERD_SEPARATORS: SeparatorChars = {
131
+ powerlineLeft: "\uE0B0", //
132
+ powerlineRight: "\uE0B2", //
133
+ powerlineThinLeft: "\uE0B1", //
134
+ powerlineThinRight: "\uE0B3", //
135
+ slash: "/",
136
+ pipe: "|",
137
+ block: "█",
138
+ space: " ",
139
+ asciiLeft: ">",
140
+ asciiRight: "<",
141
+ dot: "·",
142
+ };
143
+
144
+ export const ASCII_SEPARATORS: SeparatorChars = {
145
+ powerlineLeft: ">",
146
+ powerlineRight: "<",
147
+ powerlineThinLeft: "|",
148
+ powerlineThinRight: "|",
149
+ slash: "/",
150
+ pipe: "|",
151
+ block: "#",
152
+ space: " ",
153
+ asciiLeft: ">",
154
+ asciiRight: "<",
155
+ dot: ".",
156
+ };
157
+
158
+ /* Detect Nerd Font support via env var, WT_SESSION, or TERM_PROGRAM.
159
+ * Windows Terminal (WT_SESSION) often ships with Nerd Fonts or Cascadia Code PL. */
160
+ export function hasNerdFonts(): boolean {
161
+ if (process.env.POWERLINE_NERD_FONTS === "1") return true;
162
+ if (process.env.POWERLINE_NERD_FONTS === "0") return false;
163
+ if (process.env.GHOSTTY_RESOURCES_DIR) return true;
164
+ // Windows Terminal — many users have Nerd Fonts installed
165
+ if (process.env.WT_SESSION) return true;
166
+ const term = (process.env.TERM_PROGRAM || "").toLowerCase();
167
+ const nerdTerms = ["iterm", "wezterm", "kitty", "ghostty", "alacritty"];
168
+ return nerdTerms.some(t => term.includes(t));
169
+ }
170
+
171
+ export function getIcons(): IconSet {
172
+ const baseIcons = hasNerdFonts() ? NERD_ICONS : ASCII_ICONS;
173
+ return {
174
+ ...baseIcons,
175
+ ...sanitizeUserIconOverrides(loadThemeConfig().icons),
176
+ };
177
+ }
178
+
179
+ export function getSeparatorChars(): SeparatorChars {
180
+ return hasNerdFonts() ? NERD_SEPARATORS : ASCII_SEPARATORS;
181
+ }
@@ -0,0 +1,234 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import type { ColorValue, CustomItemPosition, CustomStatusItem, PresetDef, StatusLinePreset, StatusLineSegmentId, StatusLineSegmentOptions } from "./types.ts";
3
+
4
+ export interface PowerlineConfig {
5
+ preset: StatusLinePreset;
6
+ customItems: CustomStatusItem[];
7
+ segmentOptions: StatusLineSegmentOptions;
8
+ mouseScroll: boolean;
9
+ fixedEditor: boolean;
10
+ }
11
+
12
+ function isRecord(value: unknown): value is Record<string, unknown> {
13
+ return typeof value === "object" && value !== null && !Array.isArray(value);
14
+ }
15
+
16
+ function normalizePreset(value: unknown, presets: readonly StatusLinePreset[]): StatusLinePreset | null {
17
+ if (typeof value !== "string") return null;
18
+ const normalized = value.trim().toLowerCase();
19
+ return (presets as readonly string[]).includes(normalized) ? (normalized as StatusLinePreset) : null;
20
+ }
21
+
22
+ function normalizeCustomItemId(value: unknown): string | null {
23
+ if (typeof value !== "string") return null;
24
+ const normalized = value.trim();
25
+ if (!normalized) return null;
26
+ return /^[a-zA-Z0-9_-]+$/.test(normalized) ? normalized : null;
27
+ }
28
+
29
+ function normalizeCustomItemPosition(value: unknown): CustomItemPosition {
30
+ if (value === "left" || value === "right" || value === "secondary") return value;
31
+ return "right";
32
+ }
33
+
34
+ function normalizeCustomColor(value: unknown): ColorValue | undefined {
35
+ if (typeof value !== "string") return undefined;
36
+ const normalized = value.trim();
37
+ return normalized ? (normalized as ColorValue) : undefined;
38
+ }
39
+
40
+ function normalizeCustomPrefix(value: unknown): string | undefined {
41
+ if (typeof value !== "string") return undefined;
42
+ const normalized = value.trim();
43
+ return normalized ? normalized : undefined;
44
+ }
45
+
46
+ function normalizeCustomStatusItem(raw: unknown, idOverride?: string): CustomStatusItem | null {
47
+ if (!isRecord(raw)) return null;
48
+ const id = normalizeCustomItemId(idOverride ?? raw.id);
49
+ if (!id) return null;
50
+
51
+ const statusKey = typeof raw.statusKey === "string" && raw.statusKey.trim() ? raw.statusKey.trim() : id;
52
+
53
+ return {
54
+ id,
55
+ statusKey,
56
+ position: normalizeCustomItemPosition(raw.position),
57
+ color: normalizeCustomColor(raw.color),
58
+ prefix: normalizeCustomPrefix(raw.prefix),
59
+ hideWhenMissing: raw.hideWhenMissing !== false,
60
+ excludeFromExtensionStatuses: raw.excludeFromExtensionStatuses !== false,
61
+ };
62
+ }
63
+
64
+ function normalizeCustomItems(raw: unknown): CustomStatusItem[] {
65
+ const normalized: CustomStatusItem[] = [];
66
+
67
+ if (Array.isArray(raw)) {
68
+ for (const entry of raw) {
69
+ const item = normalizeCustomStatusItem(entry);
70
+ if (item) normalized.push(item);
71
+ }
72
+ } else if (isRecord(raw)) {
73
+ for (const [id, entry] of Object.entries(raw)) {
74
+ const item = normalizeCustomStatusItem(entry, id);
75
+ if (item) normalized.push(item);
76
+ }
77
+ }
78
+
79
+ const deduped = new Map<string, CustomStatusItem>();
80
+ for (const item of normalized) {
81
+ deduped.set(item.id, item);
82
+ }
83
+
84
+ return [...deduped.values()];
85
+ }
86
+
87
+ function normalizeSegmentOptions(raw: Record<string, unknown>): StatusLineSegmentOptions {
88
+ const options: StatusLineSegmentOptions = {};
89
+
90
+ if (isRecord(raw.model)) {
91
+ options.model = {
92
+ ...(typeof raw.model.showThinkingLevel === "boolean" ? { showThinkingLevel: raw.model.showThinkingLevel } : {}),
93
+ };
94
+ }
95
+
96
+ if (isRecord(raw.path)) {
97
+ options.path = {
98
+ ...(raw.path.mode === "basename" || raw.path.mode === "abbreviated" || raw.path.mode === "full" ? { mode: raw.path.mode } : {}),
99
+ ...(typeof raw.path.maxLength === "number" && Number.isFinite(raw.path.maxLength) && raw.path.maxLength > 0
100
+ ? { maxLength: Math.floor(raw.path.maxLength) }
101
+ : {}),
102
+ };
103
+ }
104
+
105
+ if (isRecord(raw.git)) {
106
+ options.git = {
107
+ ...(typeof raw.git.showBranch === "boolean" ? { showBranch: raw.git.showBranch } : {}),
108
+ ...(typeof raw.git.showStaged === "boolean" ? { showStaged: raw.git.showStaged } : {}),
109
+ ...(typeof raw.git.showUnstaged === "boolean" ? { showUnstaged: raw.git.showUnstaged } : {}),
110
+ ...(typeof raw.git.showUntracked === "boolean" ? { showUntracked: raw.git.showUntracked } : {}),
111
+ ...(raw.git.polling === "full" || raw.git.polling === "branch" || raw.git.polling === "off" ? { polling: raw.git.polling } : {}),
112
+ };
113
+ }
114
+
115
+ if (isRecord(raw.time)) {
116
+ options.time = {
117
+ ...(raw.time.format === "12h" || raw.time.format === "24h" ? { format: raw.time.format } : {}),
118
+ ...(typeof raw.time.showSeconds === "boolean" ? { showSeconds: raw.time.showSeconds } : {}),
119
+ };
120
+ }
121
+
122
+ return options;
123
+ }
124
+
125
+ export function mergeSegmentOptions(
126
+ defaults: StatusLineSegmentOptions = {},
127
+ overrides: StatusLineSegmentOptions = {},
128
+ ): StatusLineSegmentOptions {
129
+ return {
130
+ ...defaults,
131
+ ...overrides,
132
+ model: { ...defaults.model, ...overrides.model },
133
+ path: { ...defaults.path, ...overrides.path },
134
+ git: { ...defaults.git, ...overrides.git },
135
+ time: { ...defaults.time, ...overrides.time },
136
+ };
137
+ }
138
+
139
+ export function parsePowerlineConfig(value: unknown, presets: readonly StatusLinePreset[]): PowerlineConfig {
140
+ const defaultConfig: PowerlineConfig = { preset: "default", customItems: [], segmentOptions: {}, mouseScroll: true, fixedEditor: true };
141
+
142
+ const directPreset = normalizePreset(value, presets);
143
+ if (directPreset) return { ...defaultConfig, preset: directPreset };
144
+
145
+ if (!isRecord(value)) return defaultConfig;
146
+
147
+ return {
148
+ preset: normalizePreset(value.preset, presets) ?? defaultConfig.preset,
149
+ customItems: normalizeCustomItems(value.customItems),
150
+ segmentOptions: normalizeSegmentOptions(value),
151
+ mouseScroll: value.mouseScroll !== false,
152
+ fixedEditor: value.fixedEditor !== false,
153
+ };
154
+ }
155
+
156
+ export function mergeSegmentsWithCustomItems(presetDef: PresetDef, customItems: readonly CustomStatusItem[]): {
157
+ leftSegments: StatusLineSegmentId[];
158
+ rightSegments: StatusLineSegmentId[];
159
+ secondarySegments: StatusLineSegmentId[];
160
+ } {
161
+ const left: StatusLineSegmentId[] = [...presetDef.leftSegments];
162
+ const right: StatusLineSegmentId[] = [...presetDef.rightSegments];
163
+ const secondary: StatusLineSegmentId[] = [...(presetDef.secondarySegments ?? [])];
164
+
165
+ for (const item of customItems) {
166
+ const segmentId: StatusLineSegmentId = `custom:${item.id}`;
167
+ if (item.position === "left") left.push(segmentId);
168
+ else if (item.position === "secondary") secondary.push(segmentId);
169
+ else right.push(segmentId);
170
+ }
171
+
172
+ return { leftSegments: left, rightSegments: right, secondarySegments: secondary };
173
+ }
174
+
175
+ export function nextPowerlineSettingWithPreset(existingPowerlineSetting: unknown, preset: StatusLinePreset): unknown {
176
+ if (!isRecord(existingPowerlineSetting)) {
177
+ return preset;
178
+ }
179
+ return { ...existingPowerlineSetting, preset };
180
+ }
181
+
182
+ export function nextPowerlineSettingWithOptions(
183
+ existingPowerlineSetting: unknown,
184
+ updates: Partial<Pick<PowerlineConfig, "mouseScroll" | "fixedEditor">>,
185
+ currentPreset: StatusLinePreset,
186
+ ): unknown {
187
+ if (!isRecord(existingPowerlineSetting)) {
188
+ return { preset: currentPreset, ...updates };
189
+ }
190
+ return { ...existingPowerlineSetting, ...updates };
191
+ }
192
+
193
+ export function collectHiddenExtensionStatusKeys(customItems: readonly CustomStatusItem[]): Set<string> {
194
+ const hidden = new Set<string>();
195
+ for (const item of customItems) {
196
+ if (item.excludeFromExtensionStatuses) hidden.add(item.statusKey);
197
+ }
198
+ return hidden;
199
+ }
200
+
201
+ export function isNotificationExtensionStatus(value: string): boolean {
202
+ return value.trimStart().startsWith("[");
203
+ }
204
+
205
+ export function getNotificationExtensionStatuses(
206
+ statuses: ReadonlyMap<string, string>,
207
+ hiddenKeys: ReadonlySet<string>,
208
+ ): string[] {
209
+ const notifications: string[] = [];
210
+ for (const [statusKey, value] of statuses.entries()) {
211
+ if (hiddenKeys.has(statusKey) || !value || !isNotificationExtensionStatus(value)) {
212
+ continue;
213
+ }
214
+ notifications.push(value);
215
+ }
216
+ return notifications;
217
+ }
218
+
219
+ export function normalizeExtensionStatusValue(value: string): string | null {
220
+ if (!value || visibleWidth(value) <= 0) {
221
+ return null;
222
+ }
223
+
224
+ const stripped = value.replace(/(\x1b\[[0-9;]*m|\s|·|[|])+$/, "");
225
+ return visibleWidth(stripped) > 0 ? stripped : null;
226
+ }
227
+
228
+ export function normalizeCompactExtensionStatus(value: string): string | null {
229
+ if (isNotificationExtensionStatus(value)) {
230
+ return null;
231
+ }
232
+
233
+ return normalizeExtensionStatusValue(value);
234
+ }