@mrclrchtr/supi-extras 1.4.0 → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-core",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi core — shared infrastructure for SuPi extensions (XML context tags, config system)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -49,6 +49,7 @@ export {
49
49
  redactDebugData,
50
50
  resetDebugRegistry,
51
51
  } from "./debug-registry.ts";
52
+ export { fileToUri, resolveToolPath, stripToolPathPrefix, uriToFile } from "./path-utils.ts";
52
53
  export type { KnownRootEntry } from "./project-roots.ts";
53
54
  export {
54
55
  buildKnownRootsMap,
@@ -63,6 +64,7 @@ export {
63
64
  sortRootsBySpecificity,
64
65
  walkProject,
65
66
  } from "./project-roots.ts";
67
+ export { createRegistry, createSessionStateRegistry } from "./registry-utils.ts";
66
68
  export { getActiveBranchEntries } from "./session-utils.ts";
67
69
  export { registerSettingsCommand } from "./settings/settings-command.ts";
68
70
  export type { SettingsScope, SettingsSection } from "./settings/settings-registry.ts";
@@ -0,0 +1,40 @@
1
+ import * as path from "node:path";
2
+
3
+ /** Strip pi's optional leading `@` file-path prefix from a tool input. */
4
+ export function stripToolPathPrefix(target: string): string {
5
+ return target.startsWith("@") ? target.slice(1) : target;
6
+ }
7
+
8
+ /**
9
+ * Resolve a tool-style file path from a session cwd.
10
+ *
11
+ * Built-in pi file tools accept a leading `@` prefix in path arguments, so
12
+ * shared SuPi path helpers normalize that prefix before resolving relative
13
+ * paths.
14
+ */
15
+ export function resolveToolPath(cwd: string, target: string): string {
16
+ return path.resolve(cwd, stripToolPathPrefix(target));
17
+ }
18
+
19
+ /** Convert a file path to a file:// URI. */
20
+ export function fileToUri(filePath: string): string {
21
+ const resolved = path.resolve(filePath);
22
+ if (process.platform === "win32") {
23
+ return `file:///${resolved.replace(/\\/g, "/")}`;
24
+ }
25
+ return `file://${resolved}`;
26
+ }
27
+
28
+ /** Convert a file:// URI to a file path. */
29
+ export function uriToFile(uri: string): string {
30
+ if (!uri.startsWith("file://")) return uri;
31
+ let filePath = decodeURIComponent(uri.slice(7));
32
+ if (
33
+ process.platform === "win32" &&
34
+ filePath.startsWith("/") &&
35
+ /^[A-Za-z]:/.test(filePath.slice(1))
36
+ ) {
37
+ filePath = filePath.slice(1);
38
+ }
39
+ return filePath;
40
+ }
@@ -5,8 +5,20 @@
5
5
  // Without this, each symlink path gets its own module copy and its own Map,
6
6
  // so registrations from one instance are invisible to consumers in another.
7
7
 
8
+ import * as path from "node:path";
9
+
8
10
  const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
9
11
 
12
+ function getGlobalRegistryMap<T>(name: string): Map<string, T> {
13
+ const key = Symbol.for(SYMBOL_PREFIX + name);
14
+ let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
15
+ if (!map) {
16
+ map = new Map<string, T>();
17
+ (globalThis as Record<symbol, unknown>)[key] = map;
18
+ }
19
+ return map;
20
+ }
21
+
10
22
  /**
11
23
  * Create a named registry backed by `globalThis` + `Symbol.for`.
12
24
  *
@@ -18,16 +30,7 @@ const SYMBOL_PREFIX = "@mrclrchtr/supi-core/";
18
30
  * @returns An object with `register`, `getAll`, and `clear` functions.
19
31
  */
20
32
  export function createRegistry<T>(name: string) {
21
- const key = Symbol.for(SYMBOL_PREFIX + name);
22
-
23
- const getMap = (): Map<string, T> => {
24
- let map = (globalThis as Record<symbol, unknown>)[key] as Map<string, T> | undefined;
25
- if (!map) {
26
- map = new Map<string, T>();
27
- (globalThis as Record<symbol, unknown>)[key] = map;
28
- }
29
- return map;
30
- };
33
+ const getMap = (): Map<string, T> => getGlobalRegistryMap<T>(name);
31
34
 
32
35
  return {
33
36
  /**
@@ -52,3 +55,32 @@ export function createRegistry<T>(name: string) {
52
55
  },
53
56
  };
54
57
  }
58
+
59
+ /**
60
+ * Create a named session-state registry keyed by normalized cwd.
61
+ *
62
+ * This helper is intended for session-scoped runtime services that should be
63
+ * shared across duplicate jiti module instances while keeping package-specific
64
+ * state unions and convenience wrappers local to the calling package.
65
+ */
66
+ export function createSessionStateRegistry<TState>(name: string) {
67
+ const getMap = (): Map<string, TState> => getGlobalRegistryMap<TState>(name);
68
+ const normalizeCwd = (cwd: string): string => path.resolve(cwd);
69
+
70
+ return {
71
+ /** Get the current state for one session cwd. */
72
+ get: (cwd: string): TState | undefined => {
73
+ return getMap().get(normalizeCwd(cwd));
74
+ },
75
+
76
+ /** Store the current state for one session cwd. */
77
+ set: (cwd: string, state: TState): void => {
78
+ getMap().set(normalizeCwd(cwd), state);
79
+ },
80
+
81
+ /** Clear the current state for one session cwd. */
82
+ clear: (cwd: string): void => {
83
+ getMap().delete(normalizeCwd(cwd));
84
+ },
85
+ };
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrclrchtr/supi-extras",
3
- "version": "1.4.0",
3
+ "version": "1.6.0",
4
4
  "description": "SuPi extras — command aliases, skill shorthand, tab spinner, /supi-stash prompt stash with TUI overlay, and other small utilities",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "dependencies": {
23
23
  "clipboardy": "^5.3.1",
24
- "@mrclrchtr/supi-core": "1.4.0"
24
+ "@mrclrchtr/supi-core": "1.6.0"
25
25
  },
26
26
  "bundledDependencies": [
27
27
  "@mrclrchtr/supi-core"
@@ -15,6 +15,7 @@ import { formatTitle, signalDone } from "@mrclrchtr/supi-core/api";
15
15
  const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
16
16
  const AGENT_END_SETTLE_MS = 200;
17
17
 
18
+ // biome-ignore lint/complexity/noExcessiveLinesPerFunction: spinner state and event wiring are intentionally colocated
18
19
  export default function tabSpinner(pi: ExtensionAPI) {
19
20
  let timer: ReturnType<typeof setInterval> | null = null;
20
21
  let pendingAgentEndTimer: ReturnType<typeof setTimeout> | null = null;
@@ -24,10 +25,13 @@ export default function tabSpinner(pi: ExtensionAPI) {
24
25
  let pendingAgentEnd = false;
25
26
  let askUserActive = 0;
26
27
  let currentCtx: ExtensionContext | undefined;
28
+ let cachedSessionName: string | undefined;
29
+ let cachedCwd: string | undefined;
30
+ const unregisterBusHandlers: Array<() => void> = [];
27
31
 
28
- /** Build the current base title from session name and cwd. */
32
+ /** Build the current base title from cached cwd plus the latest safe session name lookup. */
29
33
  function title() {
30
- return formatTitle(pi.getSessionName(), currentCtx?.cwd);
34
+ return formatTitle(getSessionNameSafe(), cachedCwd);
31
35
  }
32
36
 
33
37
  function clearPendingAgentEnd() {
@@ -38,42 +42,86 @@ export default function tabSpinner(pi: ExtensionAPI) {
38
42
  }
39
43
  }
40
44
 
41
- /** Restore the base title immediately. */
42
- function stop() {
43
- clearPendingAgentEnd();
45
+ function clearSpinnerTimer() {
44
46
  if (timer) {
45
47
  clearInterval(timer);
46
48
  timer = null;
47
49
  }
50
+ }
51
+
52
+ function handleStaleContext() {
53
+ clearPendingAgentEnd();
54
+ clearSpinnerTimer();
55
+ activeCount = 0;
56
+ hasActiveAgent = false;
57
+ currentCtx = undefined;
58
+ }
59
+
60
+ function rememberContext(ctx: ExtensionContext) {
61
+ currentCtx = ctx;
62
+ cachedCwd = ctx.cwd;
63
+ cachedSessionName = getSessionNameSafe();
64
+ }
65
+
66
+ function getSessionNameSafe(): string | undefined {
67
+ try {
68
+ const next = pi.getSessionName();
69
+ if (next !== undefined) {
70
+ cachedSessionName = next;
71
+ }
72
+ return cachedSessionName;
73
+ } catch {
74
+ handleStaleContext();
75
+ return cachedSessionName;
76
+ }
77
+ }
78
+
79
+ function safelySetTitle(nextTitle: string) {
80
+ if (!currentCtx) return;
81
+ try {
82
+ currentCtx.ui.setTitle(nextTitle);
83
+ } catch {
84
+ handleStaleContext();
85
+ }
86
+ }
87
+
88
+ /** Restore the base title immediately. */
89
+ function stop() {
90
+ clearPendingAgentEnd();
91
+ clearSpinnerTimer();
48
92
  frame = 0;
49
- if (currentCtx) currentCtx.ui.setTitle(title());
93
+ const baseTitle = title();
94
+ safelySetTitle(baseTitle);
50
95
  }
51
96
 
52
97
  /** Show the ✓ done symbol in the title and play the terminal bell. */
53
98
  function showDone() {
54
99
  clearPendingAgentEnd();
55
- if (timer) {
56
- clearInterval(timer);
57
- timer = null;
58
- }
100
+ clearSpinnerTimer();
59
101
  frame = 0;
60
- if (currentCtx) signalDone(currentCtx, title());
102
+ const baseTitle = title();
103
+ if (!currentCtx) return;
104
+ try {
105
+ signalDone(currentCtx, baseTitle);
106
+ } catch {
107
+ handleStaleContext();
108
+ }
61
109
  }
62
110
 
63
111
  /** Start the spinner interval. Overwrites any ✓ shown. */
64
112
  function start() {
65
- if (timer) return;
66
- if (!currentCtx) return;
113
+ if (timer || !currentCtx) return;
67
114
  timer = setInterval(() => {
68
115
  const icon = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
69
- currentCtx?.ui.setTitle(`${icon} ${title()}`);
116
+ const baseTitle = title();
117
+ safelySetTitle(`${icon} ${baseTitle}`);
70
118
  frame++;
71
119
  }, 80);
72
120
  }
73
121
 
74
122
  function increment(ctx: ExtensionContext) {
75
123
  clearPendingAgentEnd();
76
- currentCtx = ctx;
124
+ rememberContext(ctx);
77
125
  activeCount++;
78
126
  if (activeCount === 1 && askUserActive === 0) start();
79
127
  }
@@ -81,7 +129,7 @@ export default function tabSpinner(pi: ExtensionAPI) {
81
129
  function resumePendingAgent(ctx: ExtensionContext) {
82
130
  if (!pendingAgentEnd) return;
83
131
  clearPendingAgentEnd();
84
- currentCtx = ctx;
132
+ rememberContext(ctx);
85
133
  hasActiveAgent = true;
86
134
  if (askUserActive === 0) start();
87
135
  }
@@ -113,6 +161,16 @@ export default function tabSpinner(pi: ExtensionAPI) {
113
161
  pendingAgentEndTimer.unref?.();
114
162
  }
115
163
 
164
+ function unregisterEvents() {
165
+ for (const unregister of unregisterBusHandlers.splice(0)) {
166
+ unregister();
167
+ }
168
+ }
169
+
170
+ pi.on("session_start", async (_event, ctx) => {
171
+ rememberContext(ctx);
172
+ });
173
+
116
174
  pi.on("agent_start", async (_event, ctx) => {
117
175
  if (pendingAgentEnd) {
118
176
  resumePendingAgent(ctx);
@@ -124,39 +182,39 @@ export default function tabSpinner(pi: ExtensionAPI) {
124
182
 
125
183
  pi.on("turn_start", async (_event, ctx) => resumePendingAgent(ctx));
126
184
 
127
- pi.on("agent_end", async (event, _ctx) => {
185
+ pi.on("agent_end", async (event) => {
128
186
  const retryAwareEvent = event as { willRetry?: boolean };
129
- // Extension events do not currently type `willRetry`, but honor it when
130
- // present and otherwise rely on the short settle window plus turn_start.
131
187
  if (retryAwareEvent.willRetry) return;
132
188
  hasActiveAgent = false;
133
189
  agentEnded();
134
190
  });
135
191
 
136
192
  pi.on("session_shutdown", async (_event, ctx) => {
193
+ unregisterEvents();
137
194
  activeCount = 0;
138
- currentCtx = ctx;
195
+ rememberContext(ctx);
139
196
  stop();
197
+ currentCtx = undefined;
140
198
  });
141
199
 
142
- pi.events.on("supi:working:start", () => {
143
- if (currentCtx) increment(currentCtx);
144
- });
145
- pi.events.on("supi:working:end", () => decrement());
146
-
147
- pi.events.on("supi:ask-user:start", () => {
148
- askUserActive++;
149
- // Pause the spinner so ask_user's attention title (set via signalWaiting)
150
- // is visible to the user instead of being overwritten on the next tick.
151
- if (timer) {
152
- clearInterval(timer);
153
- timer = null;
154
- }
155
- });
156
-
157
- pi.events.on("supi:ask-user:end", () => {
158
- askUserActive = Math.max(0, askUserActive - 1);
159
- // Resume the spinner if the agent (or background work) is still running.
160
- if (askUserActive === 0 && activeCount > 0) start();
161
- });
200
+ unregisterBusHandlers.push(
201
+ pi.events.on("supi:working:start", () => {
202
+ if (currentCtx) increment(currentCtx);
203
+ }),
204
+ );
205
+ unregisterBusHandlers.push(pi.events.on("supi:working:end", () => decrement()));
206
+
207
+ unregisterBusHandlers.push(
208
+ pi.events.on("supi:ask-user:start", () => {
209
+ askUserActive++;
210
+ clearSpinnerTimer();
211
+ }),
212
+ );
213
+
214
+ unregisterBusHandlers.push(
215
+ pi.events.on("supi:ask-user:end", () => {
216
+ askUserActive = Math.max(0, askUserActive - 1);
217
+ if (askUserActive === 0 && activeCount > 0) start();
218
+ }),
219
+ );
162
220
  }