@xzzpig/pi-starline 0.1.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 +22 -0
  2. package/README.md +223 -0
  3. package/extensions/starline/config.ts +1317 -0
  4. package/extensions/starline/editor-cursor.ts +55 -0
  5. package/extensions/starline/editor-factory-marker.ts +49 -0
  6. package/extensions/starline/editor-metadata-format.ts +259 -0
  7. package/extensions/starline/extension-status.ts +88 -0
  8. package/extensions/starline/footer-format.ts +279 -0
  9. package/extensions/starline/footer.ts +744 -0
  10. package/extensions/starline/format.ts +473 -0
  11. package/extensions/starline/git.ts +467 -0
  12. package/extensions/starline/icons.ts +294 -0
  13. package/extensions/starline/index.ts +651 -0
  14. package/extensions/starline/live-context.ts +75 -0
  15. package/extensions/starline/mouse/capabilities.ts +207 -0
  16. package/extensions/starline/mouse/component-tree.ts +230 -0
  17. package/extensions/starline/mouse/editor-caret.ts +498 -0
  18. package/extensions/starline/mouse/editor-hit-test.ts +133 -0
  19. package/extensions/starline/mouse/editor-mouse.ts +142 -0
  20. package/extensions/starline/mouse/editor-scroll.ts +119 -0
  21. package/extensions/starline/mouse/editor-text-cursor.ts +209 -0
  22. package/extensions/starline/mouse/editor-text-edit.ts +90 -0
  23. package/extensions/starline/mouse/hint.ts +14 -0
  24. package/extensions/starline/mouse/hit-test.ts +144 -0
  25. package/extensions/starline/mouse/index.ts +1119 -0
  26. package/extensions/starline/mouse/selection-state.ts +68 -0
  27. package/extensions/starline/mouse/tool-box.ts +290 -0
  28. package/extensions/starline/mouse/transcript-copy.ts +166 -0
  29. package/extensions/starline/mouse/word-select.ts +145 -0
  30. package/extensions/starline/package-version.ts +650 -0
  31. package/extensions/starline/paste-collapse.ts +312 -0
  32. package/extensions/starline/pill-config.ts +122 -0
  33. package/extensions/starline/pill.ts +295 -0
  34. package/extensions/starline/project-refresh.ts +104 -0
  35. package/extensions/starline/project-state.ts +66 -0
  36. package/extensions/starline/prototype-patch-registry.ts +136 -0
  37. package/extensions/starline/runtime.ts +841 -0
  38. package/extensions/starline/selector-border.ts +77 -0
  39. package/extensions/starline/session-lifecycle.ts +60 -0
  40. package/extensions/starline/settings-command.ts +925 -0
  41. package/extensions/starline/state.ts +65 -0
  42. package/extensions/starline/style.ts +528 -0
  43. package/extensions/starline/ui.ts +611 -0
  44. package/extensions/starline/user-message.ts +267 -0
  45. package/package.json +60 -0
@@ -0,0 +1,295 @@
1
+ /**
2
+ * Pill footer rendering.
3
+ *
4
+ * Draws segments as coloured blocks joined by seamless powerline arrows. The
5
+ * arrow between two pills takes the left pill's background as its foreground
6
+ * and the right pill's as its background, so the colours meet with no gap. All
7
+ * colours come from the resolved SGR sequences, never from re-extracted RGB, so
8
+ * whichever encoding Pi picked for the terminal survives intact.
9
+ */
10
+
11
+ import { stripVTControlCharacters } from "node:util";
12
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
13
+
14
+ import type { ColorSource, ColorSpec } from "./config";
15
+ import type { ExtensionStatusSegment } from "./extension-status";
16
+ import {
17
+ EXTENSION_STATUS_PREFIX,
18
+ EXTENSION_STATUS_SEGMENT,
19
+ PILL_SEGMENT_VARIABLES,
20
+ type PillConfig,
21
+ } from "./pill-config";
22
+ import { resolveBackgroundSgr, resolveColorSpec, type ThemeLike, toForegroundSgr } from "./style";
23
+
24
+ const RESET = "\x1b[0m";
25
+ const BOLD = "\x1b[1m";
26
+ const DEFAULT_BG = "\x1b[49m";
27
+
28
+ /** Text colours are the basic named codes, which every terminal renders correctly. */
29
+ const TEXT_DARK = "\x1b[30m";
30
+ const TEXT_LIGHT = "\x1b[97m";
31
+
32
+ /** Catppuccin surface1 — the neutral for segments whose spec names no colour. */
33
+ const NEUTRAL_BG = "\x1b[48;2;69;71;90m";
34
+
35
+ const SEPARATOR_GLYPHS: Record<PillConfig["separator"], string> = {
36
+ powerline: "",
37
+ "powerline-thin": "",
38
+ none: "",
39
+ };
40
+
41
+ const CAP_GLYPHS = { left: "", right: "" };
42
+
43
+ /** Divider used where a solid arrow would be invisible. */
44
+ const THIN_SEPARATOR = SEPARATOR_GLYPHS["powerline-thin"];
45
+
46
+ export type PillInput = {
47
+ /** Segment name, for diagnostics. */
48
+ key: string;
49
+ /** Segment text. Any styling it carries is stripped unless `keepStyling`. */
50
+ text: string;
51
+ /** ColorSpec the pill background is resolved from. */
52
+ spec: ColorSpec;
53
+ /**
54
+ * Keep the text's own SGR instead of recolouring it. Used for extension
55
+ * statuses configured with `colorMode: "original"`, whose foreground the
56
+ * extension chose. Those get the neutral background so the extension's
57
+ * colours stay legible instead of fighting a segment colour.
58
+ */
59
+ keepStyling?: boolean;
60
+ };
61
+
62
+ function statusInput(
63
+ status: ExtensionStatusSegment,
64
+ statusSpec: (key: string) => ColorSpec,
65
+ statusIcon: (key: string) => string,
66
+ ): PillInput {
67
+ // colorMode "original" keeps the extension's own styling, so leave its text
68
+ // exactly as it arrived rather than splicing an icon into styled output.
69
+ if (status.colorMode === "original") {
70
+ return { key: status.key, text: status.text, spec: "", keepStyling: true };
71
+ }
72
+ const icon = statusIcon(status.key);
73
+ return {
74
+ key: status.key,
75
+ text: icon ? `${icon} ${status.text}` : status.text,
76
+ spec: statusSpec(status.key),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * Turn the configured segment list into pill inputs.
82
+ *
83
+ * `extensionStatus` is not one segment but however many other extensions have
84
+ * registered, so it expands in place — minus any status already placed
85
+ * explicitly as `extensionStatus:<key>`, which would otherwise appear twice.
86
+ */
87
+ export function collectPillInputs(
88
+ segments: string[],
89
+ statuses: ExtensionStatusSegment[],
90
+ renderVariable: (name: string) => string,
91
+ specFor: (segment: string) => ColorSpec,
92
+ statusSpec: (key: string) => ColorSpec,
93
+ statusIcon: (key: string) => string = () => "",
94
+ ): PillInput[] {
95
+ const placedKeys = new Set(
96
+ segments
97
+ .filter((name) => name.startsWith(EXTENSION_STATUS_PREFIX))
98
+ .map((name) => name.slice(EXTENSION_STATUS_PREFIX.length)),
99
+ );
100
+
101
+ const inputs: PillInput[] = [];
102
+ for (const segment of segments) {
103
+ if (segment === EXTENSION_STATUS_SEGMENT) {
104
+ for (const status of statuses) {
105
+ if (!placedKeys.has(status.key)) inputs.push(statusInput(status, statusSpec, statusIcon));
106
+ }
107
+ continue;
108
+ }
109
+
110
+ if (segment.startsWith(EXTENSION_STATUS_PREFIX)) {
111
+ const key = segment.slice(EXTENSION_STATUS_PREFIX.length);
112
+ const status = statuses.find((candidate) => candidate.key === key);
113
+ if (status) inputs.push(statusInput(status, statusSpec, statusIcon));
114
+ continue;
115
+ }
116
+
117
+ const variable = PILL_SEGMENT_VARIABLES[segment];
118
+ if (!variable) continue;
119
+ inputs.push({ key: segment, text: renderVariable(variable), spec: specFor(segment) });
120
+ }
121
+
122
+ return inputs;
123
+ }
124
+
125
+ type PreparedPill = {
126
+ text: string;
127
+ bg: string;
128
+ fg: string;
129
+ bold: boolean;
130
+ };
131
+
132
+ const XTERM_CUBE_LEVELS = [0, 95, 135, 175, 215, 255];
133
+
134
+ const XTERM_BASE_RGB: ReadonlyArray<[number, number, number]> = [
135
+ [0, 0, 0],
136
+ [128, 0, 0],
137
+ [0, 128, 0],
138
+ [128, 128, 0],
139
+ [0, 0, 128],
140
+ [128, 0, 128],
141
+ [0, 128, 128],
142
+ [192, 192, 192],
143
+ [128, 128, 128],
144
+ [255, 0, 0],
145
+ [0, 255, 0],
146
+ [255, 255, 0],
147
+ [0, 0, 255],
148
+ [255, 0, 255],
149
+ [0, 255, 255],
150
+ [255, 255, 255],
151
+ ];
152
+
153
+ function xterm256ToRgb(index: number): [number, number, number] | undefined {
154
+ if (index < 0 || index > 255) return undefined;
155
+ if (index < 16) return XTERM_BASE_RGB[index];
156
+ if (index < 232) {
157
+ const offset = index - 16;
158
+ return [
159
+ XTERM_CUBE_LEVELS[Math.floor(offset / 36)] ?? 0,
160
+ XTERM_CUBE_LEVELS[Math.floor((offset % 36) / 6)] ?? 0,
161
+ XTERM_CUBE_LEVELS[offset % 6] ?? 0,
162
+ ];
163
+ }
164
+ const gray = 8 + (index - 232) * 10;
165
+ return [gray, gray, gray];
166
+ }
167
+
168
+ /** Recover the RGB a background SGR stands for, when it is knowable. */
169
+ export function backgroundSgrToRgb(bg: string): [number, number, number] | undefined {
170
+ const truecolor = /^\x1b\[48;2;(\d{1,3});(\d{1,3});(\d{1,3})m$/.exec(bg);
171
+ if (truecolor) {
172
+ return [Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3])];
173
+ }
174
+ const palette = /^\x1b\[48;5;(\d{1,3})m$/.exec(bg);
175
+ if (palette) return xterm256ToRgb(Number(palette[1]));
176
+
177
+ const named = /^\x1b\[(4[0-7]|10[0-7])m$/.exec(bg);
178
+ if (named) {
179
+ const code = Number(named[1]);
180
+ return XTERM_BASE_RGB[code >= 100 ? code - 100 + 8 : code - 40];
181
+ }
182
+ return undefined;
183
+ }
184
+
185
+ /**
186
+ * Pick a legible text colour for a background. Falls back to dark text when the
187
+ * background's colour cannot be recovered — the case for `\x1b[49m`, where the
188
+ * terminal's own default background is in play and either choice is a guess.
189
+ */
190
+ export function contrastTextSgr(bg: string): string {
191
+ const rgb = backgroundSgrToRgb(bg);
192
+ if (!rgb) return TEXT_DARK;
193
+ const [r, g, b] = rgb;
194
+ const luminance = 0.299 * r + 0.587 * g + 0.114 * b;
195
+ return luminance >= 128 ? TEXT_DARK : TEXT_LIGHT;
196
+ }
197
+
198
+ function neutralBackground(theme: ThemeLike, source: ColorSource): string {
199
+ return resolveBackgroundSgr(theme, source, "borderMuted") ?? NEUTRAL_BG;
200
+ }
201
+
202
+ function prepare(
203
+ inputs: PillInput[],
204
+ theme: ThemeLike,
205
+ source: ColorSource,
206
+ config: PillConfig,
207
+ ): PreparedPill[] {
208
+ const neutral = neutralBackground(theme, source);
209
+ const pills: PreparedPill[] = [];
210
+
211
+ for (const input of inputs) {
212
+ const text = input.keepStyling ? input.text : stripVTControlCharacters(input.text).trim();
213
+ if (!text || visibleWidth(text) === 0) continue;
214
+
215
+ if (input.keepStyling) {
216
+ pills.push({ text, bg: neutral, fg: "", bold: false });
217
+ continue;
218
+ }
219
+
220
+ const resolved = resolveColorSpec(theme, source, input.spec);
221
+ const bg = resolved.bg ?? resolveBackgroundSgr(theme, source, input.spec) ?? neutral;
222
+ // An explicit fg: in the spec wins; otherwise pick for legibility.
223
+ pills.push({
224
+ text,
225
+ bg,
226
+ fg: resolved.bg && resolved.fg ? resolved.fg : contrastTextSgr(bg),
227
+ bold: config.bold || resolved.bold,
228
+ });
229
+ }
230
+
231
+ return pills;
232
+ }
233
+
234
+ /**
235
+ * Render the pill bar. Returns "" when nothing has content, so the caller can
236
+ * fall back rather than emit a lone pair of caps.
237
+ */
238
+ export function renderPillBar(
239
+ inputs: PillInput[],
240
+ theme: ThemeLike,
241
+ source: ColorSource,
242
+ config: PillConfig,
243
+ width: number,
244
+ options: { ascii?: boolean } = {},
245
+ ): string {
246
+ const pills = prepare(inputs, theme, source, config);
247
+ if (pills.length === 0) return "";
248
+
249
+ // Nerd Font glyphs are the whole point of the shape; without them the
250
+ // background transitions alone delineate the segments.
251
+ const ascii = options.ascii === true;
252
+ const separator = ascii ? "" : SEPARATOR_GLYPHS[config.separator];
253
+ const wantsCaps = !ascii && config.caps !== "none";
254
+ const leftCap = wantsCaps && config.caps === "round" ? CAP_GLYPHS.left : "";
255
+ const rightCap = wantsCaps ? CAP_GLYPHS.right : "";
256
+
257
+ let out = "";
258
+
259
+ const firstBg = pills[0]?.bg;
260
+ if (leftCap && firstBg) {
261
+ const capFg = toForegroundSgr(firstBg);
262
+ if (capFg) out += `${DEFAULT_BG}${capFg}${leftCap}${RESET}`;
263
+ }
264
+
265
+ pills.forEach((pill, index) => {
266
+ out += `${pill.bg}${pill.fg}${pill.bold ? BOLD : ""} ${pill.text} ${RESET}`;
267
+
268
+ const next = pills[index + 1];
269
+ if (next) {
270
+ if (!separator) return;
271
+
272
+ // A solid arrow works by being the left colour on the right colour, so
273
+ // it vanishes when the two pills share a background — several segments
274
+ // default to the same theme colour, which merged them into one blob.
275
+ // Fall back to the thin divider in the text colour, which stays visible.
276
+ if (pill.bg === next.bg) {
277
+ out += `${pill.bg}${pill.fg || contrastTextSgr(pill.bg)}${THIN_SEPARATOR}${RESET}`;
278
+ return;
279
+ }
280
+
281
+ const arrowFg = toForegroundSgr(pill.bg);
282
+ // Without a usable foreground the arrow would be invisible; letting the
283
+ // backgrounds abut is better than drawing a glyph in the wrong colour.
284
+ out += arrowFg ? `${next.bg}${arrowFg}${separator}${RESET}` : "";
285
+ return;
286
+ }
287
+
288
+ if (rightCap) {
289
+ const capFg = toForegroundSgr(pill.bg);
290
+ if (capFg) out += `${DEFAULT_BG}${capFg}${rightCap}${RESET}`;
291
+ }
292
+ });
293
+
294
+ return width > 0 ? truncateToWidth(out, width, "") : out;
295
+ }
@@ -0,0 +1,104 @@
1
+ export type StopProjectRefreshInterval = () => void;
2
+
3
+ export type ScheduleProjectRefreshOptions = {
4
+ force?: boolean;
5
+ };
6
+
7
+ export type ProjectRefreshScheduler<T> = {
8
+ schedule: (target: T, options?: ScheduleProjectRefreshOptions) => void;
9
+ stop: () => void;
10
+ };
11
+
12
+ export const PROJECT_REFRESH_THROTTLE_MS = 5_000;
13
+
14
+ export function startProjectRefreshInterval(
15
+ intervalMs: number,
16
+ refresh: () => void,
17
+ ): StopProjectRefreshInterval {
18
+ if (intervalMs <= 0) return () => {};
19
+
20
+ const timer = setInterval(refresh, intervalMs);
21
+ timer.unref?.();
22
+
23
+ return () => clearInterval(timer);
24
+ }
25
+
26
+ export function createProjectRefreshScheduler<T>(
27
+ refresh: (target: T) => Promise<void>,
28
+ afterRefresh: () => void,
29
+ throttleMs = PROJECT_REFRESH_THROTTLE_MS,
30
+ ): ProjectRefreshScheduler<T> {
31
+ let refreshInFlight = false;
32
+ let refreshPending = false;
33
+ let pendingTarget: T | undefined;
34
+ let delayedRefresh: ReturnType<typeof setTimeout> | undefined;
35
+ let lastRefreshStartedAt: number | undefined;
36
+ let generation = 0;
37
+
38
+ const clearDelayedRefresh = () => {
39
+ if (!delayedRefresh) return;
40
+ clearTimeout(delayedRefresh);
41
+ delayedRefresh = undefined;
42
+ };
43
+
44
+ const runRefresh = (target: T) => {
45
+ clearDelayedRefresh();
46
+ if (refreshInFlight) {
47
+ refreshPending = true;
48
+ pendingTarget = target;
49
+ return;
50
+ }
51
+
52
+ const currentGeneration = generation;
53
+ refreshInFlight = true;
54
+ lastRefreshStartedAt = Date.now();
55
+ void refresh(target)
56
+ .catch(() => undefined)
57
+ .finally(() => {
58
+ if (currentGeneration !== generation) return;
59
+ refreshInFlight = false;
60
+ afterRefresh();
61
+ if (refreshPending) {
62
+ refreshPending = false;
63
+ const nextTarget = pendingTarget ?? target;
64
+ pendingTarget = undefined;
65
+ schedule(nextTarget);
66
+ }
67
+ });
68
+ };
69
+
70
+ const schedule = (target: T, options: ScheduleProjectRefreshOptions = {}) => {
71
+ if (options.force || throttleMs <= 0 || lastRefreshStartedAt === undefined) {
72
+ runRefresh(target);
73
+ return;
74
+ }
75
+
76
+ const delayMs = Math.max(0, throttleMs - (Date.now() - lastRefreshStartedAt));
77
+ if (delayMs === 0) {
78
+ runRefresh(target);
79
+ return;
80
+ }
81
+
82
+ pendingTarget = target;
83
+ if (delayedRefresh) return;
84
+ delayedRefresh = setTimeout(() => {
85
+ delayedRefresh = undefined;
86
+ const nextTarget = pendingTarget ?? target;
87
+ pendingTarget = undefined;
88
+ runRefresh(nextTarget);
89
+ }, delayMs);
90
+ delayedRefresh.unref?.();
91
+ };
92
+
93
+ return {
94
+ schedule,
95
+ stop() {
96
+ generation += 1;
97
+ clearDelayedRefresh();
98
+ refreshInFlight = false;
99
+ refreshPending = false;
100
+ pendingTarget = undefined;
101
+ lastRefreshStartedAt = undefined;
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,66 @@
1
+ import type { GitHost, GitReadResult } from "./git";
2
+ import { emptyGitStatus } from "./git";
3
+ import type { PackageVersionReadResult } from "./package-version";
4
+ import type { RuntimeReadResult } from "./runtime";
5
+ import type { FooterState } from "./state";
6
+
7
+ /**
8
+ * Apply a project refresh (git + runtime) onto footer state, preserving
9
+ * last-good values on transient errors and clearing on cwd change / not-a-repo.
10
+ *
11
+ * Returns the cwd to store as `previousCwd` for the next refresh.
12
+ */
13
+ export function applyProjectRefreshToState(
14
+ state: FooterState,
15
+ args: {
16
+ cwd: string;
17
+ previousCwd: string | undefined;
18
+ git: GitReadResult;
19
+ gitHost?: GitHost;
20
+ runtime: RuntimeReadResult;
21
+ packageVersion?: PackageVersionReadResult;
22
+ },
23
+ ): string {
24
+ const cwdChanged = args.previousCwd !== undefined && args.previousCwd !== args.cwd;
25
+
26
+ if (cwdChanged) {
27
+ Object.assign(state, emptyGitStatus());
28
+ state.runtime = undefined;
29
+ state.gitHost = undefined;
30
+ state.packageVersion = undefined;
31
+ }
32
+
33
+ // Undefined means "not asked for this refresh" — keep the last-good value
34
+ // rather than dropping the icon whenever gitHostIcon is off.
35
+ if (args.gitHost !== undefined) state.gitHost = args.gitHost;
36
+ if (args.git.kind === "not_a_repo") state.gitHost = undefined;
37
+
38
+ if (args.git.kind === "ok") {
39
+ Object.assign(state, args.git.status);
40
+ } else if (args.git.kind === "not_a_repo") {
41
+ Object.assign(state, emptyGitStatus());
42
+ }
43
+ // kind === "error": keep previous git fields (unless cwdChanged already cleared)
44
+
45
+ if (args.runtime.kind === "ok") {
46
+ state.runtime = args.runtime.runtime;
47
+ } else if (cwdChanged && args.runtime.kind === "error") {
48
+ // Already cleared above; keep undefined.
49
+ state.runtime = undefined;
50
+ }
51
+ // error + same cwd: keep previous runtime
52
+
53
+ if (args.packageVersion !== undefined) {
54
+ if (args.packageVersion.kind === "ok") {
55
+ // `null` means "no manifest in this cwd"; clear so the segment disappears
56
+ // even on the same cwd when the user removes their manifest.
57
+ state.packageVersion = args.packageVersion.result ?? undefined;
58
+ } else if (!cwdChanged) {
59
+ // error + same cwd: keep previous packageVersion (last-good semantics)
60
+ } else {
61
+ state.packageVersion = undefined;
62
+ }
63
+ }
64
+
65
+ return args.cwd;
66
+ }
@@ -0,0 +1,136 @@
1
+ export const STARLINE_PROTOTYPE_PATCH_REGISTRY = Symbol.for("pi-starline.prototype-patch-registry");
2
+
3
+ /**
4
+ * The key this package used before it was renamed from pi-zentui. Read AND
5
+ * written, alongside the new key: package load order is not controlled by
6
+ * either side, so whichever of pi-starline/pi-zentui creates the registry
7
+ * first must expose it under both keys. Otherwise a pi-starline-first load
8
+ * (the common order, since it sorts first alphabetically) would leave a
9
+ * later-loading pi-zentui unable to find it, and each package would patch a
10
+ * prototype the other has already patched.
11
+ */
12
+ const LEGACY_PROTOTYPE_PATCH_REGISTRY = Symbol.for("pi-zentui.prototype-patch-registry");
13
+
14
+ type PrototypePatchAdapter =
15
+ | "user-message-render"
16
+ | "user-message-invalidate"
17
+ | "selector-border-render"
18
+ | "mouse-viewport-input"
19
+ | "mouse-wheel"
20
+ | "mouse-selection-event"
21
+ | "mouse-copy"
22
+ | "mouse-word-selection";
23
+
24
+ type PrototypeMethodName = string;
25
+
26
+ type PrototypeMethod = (this: unknown, ...args: unknown[]) => unknown;
27
+
28
+ type PatchInvocation = {
29
+ predecessor: PrototypeMethod;
30
+ receiver: unknown;
31
+ args: unknown[];
32
+ };
33
+
34
+ type PatchBehavior = (invocation: PatchInvocation) => unknown;
35
+
36
+ type Registration = {
37
+ token: symbol;
38
+ behavior?: PatchBehavior;
39
+ };
40
+
41
+ type PatchRecord = {
42
+ method: PrototypeMethodName;
43
+ predecessor: PrototypeMethod;
44
+ wrapper: PrototypeMethod;
45
+ registration?: Registration;
46
+ };
47
+
48
+ type PatchRegistry = Map<PrototypePatchAdapter, PatchRecord>;
49
+
50
+ type PatchTarget = Record<PropertyKey, unknown>;
51
+
52
+ function registryFor(target: PatchTarget): PatchRegistry {
53
+ const existing =
54
+ target[STARLINE_PROTOTYPE_PATCH_REGISTRY] ?? target[LEGACY_PROTOTYPE_PATCH_REGISTRY];
55
+ if (existing instanceof Map) return existing as PatchRegistry;
56
+ const registry: PatchRegistry = new Map();
57
+ // One Map, exposed under both keys, so whichever package looks second
58
+ // (in either load order) adopts this same registry instead of creating
59
+ // its own and double-patching.
60
+ Object.defineProperty(target, STARLINE_PROTOTYPE_PATCH_REGISTRY, {
61
+ value: registry,
62
+ configurable: true,
63
+ });
64
+ Object.defineProperty(target, LEGACY_PROTOTYPE_PATCH_REGISTRY, {
65
+ value: registry,
66
+ configurable: true,
67
+ });
68
+ return registry;
69
+ }
70
+
71
+ function createCleanup(
72
+ target: PatchTarget,
73
+ method: PrototypeMethodName,
74
+ adapter: PrototypePatchAdapter,
75
+ registry: PatchRegistry,
76
+ record: PatchRecord,
77
+ token: symbol,
78
+ ): () => void {
79
+ let cleaned = false;
80
+ return () => {
81
+ if (cleaned) return;
82
+ cleaned = true;
83
+ if (record.registration?.token !== token) return;
84
+ record.registration.behavior = undefined;
85
+ record.registration = undefined;
86
+
87
+ const current = registry.get(adapter);
88
+ if (current !== record) return;
89
+ if (target[method] === record.wrapper) target[method] = record.predecessor;
90
+ registry.delete(adapter);
91
+ if (registry.size === 0) {
92
+ delete target[STARLINE_PROTOTYPE_PATCH_REGISTRY];
93
+ delete target[LEGACY_PROTOTYPE_PATCH_REGISTRY];
94
+ }
95
+ };
96
+ }
97
+
98
+ export function installPrototypePatch(
99
+ targetValue: object,
100
+ method: PrototypeMethodName,
101
+ adapter: PrototypePatchAdapter,
102
+ behavior: PatchBehavior,
103
+ ): () => void {
104
+ const target = targetValue as PatchTarget;
105
+ const registry = registryFor(target);
106
+ let record = registry.get(adapter);
107
+
108
+ if (!(record && record.method === method && target[method] === record.wrapper)) {
109
+ const predecessor = target[method];
110
+ if (typeof predecessor !== "function") {
111
+ throw new TypeError(`Cannot patch ${method}: predecessor is not a function`);
112
+ }
113
+ const nextRecord: PatchRecord = {
114
+ method,
115
+ predecessor: predecessor as PrototypeMethod,
116
+ wrapper: () => undefined,
117
+ };
118
+ const wrapper: PrototypeMethod = function starlinePrototypeWrapper(
119
+ this: unknown,
120
+ ...args: unknown[]
121
+ ): unknown {
122
+ const activeBehavior = nextRecord.registration?.behavior;
123
+ return activeBehavior
124
+ ? activeBehavior({ predecessor: nextRecord.predecessor, receiver: this, args })
125
+ : Reflect.apply(nextRecord.predecessor, this, args);
126
+ };
127
+ nextRecord.wrapper = wrapper;
128
+ record = nextRecord;
129
+ registry.set(adapter, record);
130
+ target[method] = wrapper;
131
+ }
132
+
133
+ const token = Symbol(adapter);
134
+ record.registration = { token, behavior };
135
+ return createCleanup(target, method, adapter, registry, record, token);
136
+ }