@groeponline/pi-wishcraft 0.17.3

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 (106) hide show
  1. package/AGENTS.md +68 -0
  2. package/CHANGELOG.md +724 -0
  3. package/CONTRIBUTING.md +37 -0
  4. package/README.md +648 -0
  5. package/RELEASE.md +117 -0
  6. package/ROADMAP.md +52 -0
  7. package/bash-mode/completion-providers.ts +269 -0
  8. package/bash-mode/completion.ts +416 -0
  9. package/bash-mode/editor-ghost.ts +40 -0
  10. package/bash-mode/editor-input.ts +80 -0
  11. package/bash-mode/editor.ts +437 -0
  12. package/bash-mode/history.ts +263 -0
  13. package/bash-mode/shell-session.ts +286 -0
  14. package/bash-mode/transcript.ts +108 -0
  15. package/bash-mode/types.ts +80 -0
  16. package/index.ts +6 -0
  17. package/package.json +55 -0
  18. package/queue/store.ts +443 -0
  19. package/queue/types.ts +54 -0
  20. package/src/config/custom-items.ts +182 -0
  21. package/src/config/extension-statuses.ts +51 -0
  22. package/src/config/layout.ts +60 -0
  23. package/src/config/parse.ts +127 -0
  24. package/src/config/powerline-config.ts +18 -0
  25. package/src/config/presets.ts +245 -0
  26. package/src/config/primitives.ts +117 -0
  27. package/src/config/segment-ids.ts +114 -0
  28. package/src/config/segment-options.ts +128 -0
  29. package/src/config/settings-patch.ts +26 -0
  30. package/src/config/types.ts +277 -0
  31. package/src/core/frontmatter.ts +40 -0
  32. package/src/editor/autocomplete-chain.ts +41 -0
  33. package/src/extension/activate.ts +28 -0
  34. package/src/extension/bash-mode-actions.ts +104 -0
  35. package/src/extension/commands.ts +268 -0
  36. package/src/extension/constants.ts +46 -0
  37. package/src/extension/custom-editor.ts +406 -0
  38. package/src/extension/git-invalidation.ts +40 -0
  39. package/src/extension/layout.ts +160 -0
  40. package/src/extension/menu-views.ts +393 -0
  41. package/src/extension/powerline-widgets.ts +95 -0
  42. package/src/extension/prompt-history.ts +219 -0
  43. package/src/extension/queue-commands.ts +245 -0
  44. package/src/extension/queue-context.ts +12 -0
  45. package/src/extension/queue-integration.ts +434 -0
  46. package/src/extension/segment-context.ts +212 -0
  47. package/src/extension/session-lifecycle.ts +373 -0
  48. package/src/extension/settings-io.ts +202 -0
  49. package/src/extension/shortcuts-config.ts +357 -0
  50. package/src/extension/shortcuts-router.ts +383 -0
  51. package/src/extension/skills/inline-invocation.ts +174 -0
  52. package/src/extension/skills/ook.md +6 -0
  53. package/src/extension/skills/test.md +6 -0
  54. package/src/extension/stale-context.ts +10 -0
  55. package/src/extension/stash-history.ts +103 -0
  56. package/src/extension/state.ts +159 -0
  57. package/src/extension/status-line-renderers.ts +222 -0
  58. package/src/extension/types.ts +97 -0
  59. package/src/extension/vibe-command.ts +160 -0
  60. package/src/extension/welcome-control.ts +27 -0
  61. package/src/extension/welcome-integration.ts +153 -0
  62. package/src/git/status.ts +332 -0
  63. package/src/paths/agent-dirs.ts +67 -0
  64. package/src/render/timer.ts +46 -0
  65. package/src/segments/core.ts +256 -0
  66. package/src/segments/custom.ts +114 -0
  67. package/src/segments/index.ts +3 -0
  68. package/src/segments/registry.ts +87 -0
  69. package/src/segments/shared.ts +36 -0
  70. package/src/segments/system.ts +235 -0
  71. package/src/segments/usage.ts +178 -0
  72. package/src/shell/cd-command.ts +190 -0
  73. package/src/shortcuts/matching.ts +61 -0
  74. package/src/theme/colors.ts +60 -0
  75. package/src/theme/icons.ts +175 -0
  76. package/src/theme/separators.ts +41 -0
  77. package/src/theme/theme.ts +211 -0
  78. package/src/tools/graph.ts +75 -0
  79. package/src/tools/patch.ts +179 -0
  80. package/src/tools/ripgrep.ts +104 -0
  81. package/src/usage/context.ts +97 -0
  82. package/src/usage/ledger.ts +293 -0
  83. package/src/usage/rates.ts +155 -0
  84. package/src/welcome/auto-dismiss.ts +43 -0
  85. package/src/welcome/banner.ts +68 -0
  86. package/src/welcome/discover.ts +234 -0
  87. package/src/welcome/format.ts +18 -0
  88. package/src/welcome/index.ts +5 -0
  89. package/src/welcome/layout.ts +36 -0
  90. package/src/welcome/overlay.ts +80 -0
  91. package/src/welcome/renderer.ts +157 -0
  92. package/src/welcome/sessions.ts +107 -0
  93. package/src/welcome/types.ts +41 -0
  94. package/src/welcome/widgets/graph-widget.ts +25 -0
  95. package/src/welcome/widgets/index.ts +20 -0
  96. package/src/welcome/widgets/queue-widget.ts +26 -0
  97. package/src/welcome/widgets/sessions-widget.ts +23 -0
  98. package/src/welcome/widgets/shortcuts-widget.ts +17 -0
  99. package/src/welcome/widgets/system-widget.ts +29 -0
  100. package/src/working-vibes/generate.ts +144 -0
  101. package/src/working-vibes/index.ts +24 -0
  102. package/src/working-vibes/manager.ts +198 -0
  103. package/src/working-vibes/provider.ts +163 -0
  104. package/src/working-vibes/storage.ts +357 -0
  105. package/theme.example.json +24 -0
  106. package/tsconfig.json +13 -0
@@ -0,0 +1,153 @@
1
+ import {
2
+ WelcomeComponent,
3
+ WelcomeHeader,
4
+ discoverLoadedCounts,
5
+ getRecentSessions,
6
+ } from "../welcome/index.ts";
7
+ import { estimateInitialContextTokens } from "../usage/context.ts";
8
+ import { isRecord } from "./settings-io.ts";
9
+ import type { RuntimeState } from "./types.ts";
10
+ import { getQueueContext } from "./queue-context.ts";
11
+
12
+ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
13
+ const modelName = ctx.model?.name || ctx.model?.id || "No model";
14
+ const providerName = ctx.model?.provider || "Unknown";
15
+ const loadedCounts = discoverLoadedCounts();
16
+ const recentSessions = getRecentSessions(3);
17
+ const initialContextTokens = estimateInitialContextTokens(ctx);
18
+ const queueSummary = rt.queueStore.summarize(getQueueContext(ctx), false);
19
+ const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
20
+ const hasStash = rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
21
+
22
+ const header = new WelcomeHeader(
23
+ modelName,
24
+ providerName,
25
+ recentSessions,
26
+ loadedCounts,
27
+ initialContextTokens,
28
+ queueCount,
29
+ hasStash,
30
+ );
31
+ rt.welcomeHeaderActive = true;
32
+
33
+ ctx.ui.setHeader(() => {
34
+ return {
35
+ render(width: number): string[] {
36
+ return header.render(width);
37
+ },
38
+ invalidate() {
39
+ header.invalidate();
40
+ },
41
+ };
42
+ });
43
+ }
44
+
45
+ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
46
+ const modelName = ctx.model?.name || ctx.model?.id || "No model";
47
+ const providerName = ctx.model?.provider || "Unknown";
48
+ const loadedCounts = discoverLoadedCounts();
49
+ const recentSessions = getRecentSessions(3);
50
+
51
+ const overlaySessionGeneration = rt.sessionGeneration;
52
+
53
+ // Small delay to let pi-mono finish initialization
54
+ setTimeout(() => {
55
+ if (
56
+ !rt.enabled ||
57
+ rt.welcomeOverlayShouldDismiss ||
58
+ rt.isStreaming ||
59
+ overlaySessionGeneration !== rt.sessionGeneration
60
+ ) {
61
+ rt.welcomeOverlayShouldDismiss = false;
62
+ return;
63
+ }
64
+
65
+ const sessionEvents = ctx.sessionManager?.getBranch?.() ?? [];
66
+ const hasActivity = sessionEvents.some((entry: unknown) => {
67
+ if (!isRecord(entry)) return false;
68
+ if (entry.type === "tool_call" || entry.type === "tool_result")
69
+ return true;
70
+ return (
71
+ entry.type === "message" &&
72
+ isRecord(entry.message) &&
73
+ entry.message.role === "assistant"
74
+ );
75
+ });
76
+ if (hasActivity) {
77
+ return;
78
+ }
79
+
80
+ const initialContextTokens = estimateInitialContextTokens(ctx);
81
+ const queueSummary = rt.queueStore.summarize(getQueueContext(ctx), false);
82
+ const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
83
+ const hasStash = rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
84
+
85
+ ctx.ui
86
+ .custom(
87
+ (
88
+ tui: any,
89
+ _theme: any,
90
+ _keybindings: any,
91
+ done: (result: void) => void,
92
+ ) => {
93
+ const welcome = new WelcomeComponent(
94
+ modelName,
95
+ providerName,
96
+ recentSessions,
97
+ loadedCounts,
98
+ initialContextTokens,
99
+ queueCount,
100
+ hasStash,
101
+ );
102
+
103
+ let countdown = 30;
104
+ let dismissed = false;
105
+ let interval: ReturnType<typeof setInterval> | null = null;
106
+
107
+ const dismiss = () => {
108
+ if (dismissed) return;
109
+ dismissed = true;
110
+ if (interval) clearInterval(interval);
111
+ rt.dismissWelcomeOverlay = null;
112
+ done();
113
+ };
114
+
115
+ interval = setInterval(() => {
116
+ if (dismissed) return;
117
+ countdown--;
118
+ welcome.setCountdown(countdown);
119
+ tui.requestRender();
120
+ if (countdown <= 0) dismiss();
121
+ }, 1000);
122
+
123
+ rt.dismissWelcomeOverlay = dismiss;
124
+
125
+ if (rt.welcomeOverlayShouldDismiss) {
126
+ rt.welcomeOverlayShouldDismiss = false;
127
+ dismiss();
128
+ }
129
+
130
+ return {
131
+ focused: false,
132
+ invalidate: () => welcome.invalidate(),
133
+ render: (width: number) => welcome.render(width),
134
+ handleInput: () => dismiss(),
135
+ dispose: () => {
136
+ dismissed = true;
137
+ if (interval) clearInterval(interval);
138
+ },
139
+ };
140
+ },
141
+ {
142
+ overlay: true,
143
+ overlayOptions: () => ({
144
+ verticalAlign: "center",
145
+ horizontalAlign: "center",
146
+ }),
147
+ },
148
+ )
149
+ .catch((error: unknown) => {
150
+ console.debug("[powerline-footer] Welcome overlay failed:", error);
151
+ });
152
+ }, 100);
153
+ }
@@ -0,0 +1,332 @@
1
+ import { spawn } from "node:child_process";
2
+ import type { GitStatus } from "../config/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
+ /** Known git hosting providers we render a dedicated icon for. */
19
+ export type GitHost = "github" | "gitlab" | "bitbucket" | "other";
20
+
21
+ interface CachedRemoteHost {
22
+ host: GitHost | null;
23
+ timestamp: number;
24
+ }
25
+
26
+ const CACHE_TTL_MS = 1000; // 1 second for file status
27
+ const BRANCH_TTL_MS = 500; // Shorter TTL so branch updates quickly after invalidation
28
+ const REMOTE_TTL_MS = 60_000; // Origin remote almost never changes within a session
29
+ let cachedStatus: CachedGitStatus | null = null;
30
+ let cachedBranch: CachedBranch | null = null;
31
+ let cachedRemoteHost: CachedRemoteHost | null = null;
32
+ let pendingRemoteFetch: Promise<void> | null = null;
33
+ let pendingFetch: Promise<void> | null = null;
34
+ let pendingBranchFetch: Promise<void> | null = null;
35
+ let invalidationCounter = 0; // Track invalidations to prevent stale updates
36
+ let branchInvalidationCounter = 0;
37
+ const updateListeners = new Set<() => void>();
38
+
39
+ function notifyGitUpdate(): void {
40
+ for (const listener of updateListeners) listener();
41
+ }
42
+
43
+ export function subscribeGitUpdates(listener: () => void): () => void {
44
+ updateListeners.add(listener);
45
+ return () => updateListeners.delete(listener);
46
+ }
47
+
48
+ /**
49
+ * Parse git status --porcelain output
50
+ *
51
+ * Format: XY filename
52
+ * X = index status, Y = working tree status
53
+ * ?? = untracked
54
+ * Other X values = staged
55
+ * Other Y values = unstaged
56
+ */
57
+ function parseGitStatusOutput(output: string): {
58
+ staged: number;
59
+ unstaged: number;
60
+ untracked: number;
61
+ } {
62
+ let staged = 0;
63
+ let unstaged = 0;
64
+ let untracked = 0;
65
+
66
+ for (const line of output.split("\n")) {
67
+ if (!line) continue;
68
+ const x = line[0];
69
+ const y = line[1];
70
+
71
+ if (x === "?" && y === "?") {
72
+ untracked++;
73
+ continue;
74
+ }
75
+
76
+ // X position (index/staged)
77
+ if (x && x !== " " && x !== "?") {
78
+ staged++;
79
+ }
80
+
81
+ // Y position (working tree/unstaged)
82
+ if (y && y !== " ") {
83
+ unstaged++;
84
+ }
85
+ }
86
+
87
+ return { staged, unstaged, untracked };
88
+ }
89
+
90
+ function runGit(args: string[], timeoutMs = 200): Promise<string | null> {
91
+ return new Promise((resolve) => {
92
+ const proc = spawn("git", args, {
93
+ stdio: ["ignore", "pipe", "pipe"],
94
+ });
95
+
96
+ let stdout = "";
97
+ let resolved = false;
98
+
99
+ const finish = (result: string | null) => {
100
+ if (resolved) return;
101
+ resolved = true;
102
+ clearTimeout(timeoutId);
103
+ resolve(result);
104
+ };
105
+
106
+ proc.stdout.on("data", (data) => {
107
+ stdout += data.toString();
108
+ });
109
+
110
+ proc.on("close", (code) => {
111
+ finish(code === 0 ? stdout.trim() : null);
112
+ });
113
+
114
+ proc.on("error", () => {
115
+ finish(null);
116
+ });
117
+
118
+ const timeoutId = setTimeout(() => {
119
+ proc.kill();
120
+ finish(null);
121
+ }, timeoutMs);
122
+ });
123
+ }
124
+
125
+ /**
126
+ * Fetch current git branch asynchronously.
127
+ * For detached HEAD, returns the short commit SHA (matches provider's "detached" behavior).
128
+ */
129
+ async function fetchGitBranch(): Promise<string | null> {
130
+ const branch = await runGit(["branch", "--show-current"]);
131
+ if (branch === null) return null;
132
+ if (branch) return branch;
133
+
134
+ const sha = await runGit(["rev-parse", "--short", "HEAD"]);
135
+ return sha ? `${sha} (detached)` : "detached";
136
+ }
137
+
138
+ /**
139
+ * Classify an origin remote URL into a known hosting provider. Handles both
140
+ * SSH (`git@host:owner/repo`, `ssh://git@host/…`) and HTTP(S) forms, and
141
+ * treats sub-domains (e.g. `www.github.com`) and any non-empty remote we
142
+ * don't recognize as a generic git host.
143
+ */
144
+ export function detectGitHost(remoteUrl: string | null): GitHost | null {
145
+ if (!remoteUrl) return null;
146
+ const trimmed = remoteUrl.trim();
147
+ if (!trimmed) return null;
148
+
149
+ let host: string;
150
+ const scpLike = /^[^/@]+@([^:/]+):/.exec(trimmed);
151
+ if (scpLike) {
152
+ host = scpLike[1]!;
153
+ } else {
154
+ try {
155
+ host = new URL(trimmed).hostname;
156
+ } catch {
157
+ return "other";
158
+ }
159
+ }
160
+
161
+ host = host.toLowerCase().replace(/^www\./, "");
162
+ if (host === "github.com" || host.endsWith(".github.com")) return "github";
163
+ if (host === "gitlab.com" || host.endsWith(".gitlab.com")) return "gitlab";
164
+ if (host === "bitbucket.org" || host.endsWith(".bitbucket.org"))
165
+ return "bitbucket";
166
+ return "other";
167
+ }
168
+
169
+ /**
170
+ * Fetch the origin remote host asynchronously and cache the result.
171
+ */
172
+ async function fetchRemoteHost(): Promise<GitHost | null> {
173
+ const url = await runGit(["remote", "get-url", "origin"]);
174
+ return detectGitHost(url);
175
+ }
176
+
177
+ /**
178
+ * Get the origin remote's hosting provider with a long TTL cache. Returns the
179
+ * cached value immediately (or null before the first fetch completes) and
180
+ * refreshes in the background, matching the branch/status caching pattern.
181
+ */
182
+ export function getGitRemoteHost(): GitHost | null {
183
+ const now = Date.now();
184
+ if (cachedRemoteHost && now - cachedRemoteHost.timestamp < REMOTE_TTL_MS) {
185
+ return cachedRemoteHost.host;
186
+ }
187
+
188
+ if (!pendingRemoteFetch) {
189
+ pendingRemoteFetch = fetchRemoteHost()
190
+ .then((host) => {
191
+ cachedRemoteHost = { host, timestamp: Date.now() };
192
+ notifyGitUpdate();
193
+ })
194
+ .catch(() => {
195
+ cachedRemoteHost = { host: null, timestamp: Date.now() };
196
+ notifyGitUpdate();
197
+ })
198
+ .finally(() => {
199
+ pendingRemoteFetch = null;
200
+ });
201
+ }
202
+
203
+ return cachedRemoteHost ? cachedRemoteHost.host : null;
204
+ }
205
+
206
+ /**
207
+ * Fetch git status asynchronously
208
+ */
209
+ async function fetchGitStatus(): Promise<{
210
+ staged: number;
211
+ unstaged: number;
212
+ untracked: number;
213
+ } | null> {
214
+ const output = await runGit(["status", "--porcelain"], 500);
215
+ if (output === null) return null;
216
+ return parseGitStatusOutput(output);
217
+ }
218
+
219
+ /**
220
+ * Get the current git branch with caching.
221
+ * Falls back to provider branch if our cache is empty.
222
+ */
223
+ export function getCurrentBranch(providerBranch: string | null): string | null {
224
+ const now = Date.now();
225
+
226
+ // Return cached if fresh
227
+ if (cachedBranch && now - cachedBranch.timestamp < BRANCH_TTL_MS) {
228
+ return cachedBranch.branch;
229
+ }
230
+
231
+ // Trigger background fetch if not already pending
232
+ if (!pendingBranchFetch) {
233
+ const fetchId = branchInvalidationCounter;
234
+ pendingBranchFetch = fetchGitBranch().then((result) => {
235
+ // Cache result if no invalidation happened (including null for non-git dirs)
236
+ if (fetchId === branchInvalidationCounter) {
237
+ cachedBranch = {
238
+ branch: result,
239
+ timestamp: Date.now(),
240
+ };
241
+ notifyGitUpdate();
242
+ }
243
+ pendingBranchFetch = null;
244
+ });
245
+ }
246
+
247
+ // Return stale cache while refreshing; only use provider before first fetch
248
+ return cachedBranch ? cachedBranch.branch : providerBranch;
249
+ }
250
+
251
+ /**
252
+ * Get git status with caching.
253
+ * Returns cached value if within TTL, otherwise triggers async fetch.
254
+ * This is designed for synchronous render() calls - returns last known value
255
+ * while refreshing in background.
256
+ */
257
+ export function getGitStatus(
258
+ providerBranch: string | null,
259
+ pollingMode: GitPollingMode = "full",
260
+ ): GitStatus {
261
+ const now = Date.now();
262
+ const branch =
263
+ pollingMode === "off" ? providerBranch : getCurrentBranch(providerBranch);
264
+
265
+ if (pollingMode !== "full") {
266
+ return { branch, staged: 0, unstaged: 0, untracked: 0 };
267
+ }
268
+
269
+ // Return cached if fresh
270
+ if (cachedStatus && now - cachedStatus.timestamp < CACHE_TTL_MS) {
271
+ return {
272
+ branch,
273
+ staged: cachedStatus.staged,
274
+ unstaged: cachedStatus.unstaged,
275
+ untracked: cachedStatus.untracked,
276
+ };
277
+ }
278
+
279
+ // Trigger background fetch if not already pending
280
+ if (!pendingFetch) {
281
+ const fetchId = invalidationCounter; // Capture current counter
282
+ pendingFetch = fetchGitStatus().then((result) => {
283
+ // Cache result if no invalidation happened (including null for non-git dirs)
284
+ if (fetchId === invalidationCounter) {
285
+ cachedStatus = result
286
+ ? {
287
+ staged: result.staged,
288
+ unstaged: result.unstaged,
289
+ untracked: result.untracked,
290
+ timestamp: Date.now(),
291
+ }
292
+ : { staged: 0, unstaged: 0, untracked: 0, timestamp: Date.now() };
293
+ notifyGitUpdate();
294
+ }
295
+ pendingFetch = null;
296
+ });
297
+ }
298
+
299
+ // Return last cached or empty
300
+ if (cachedStatus) {
301
+ return {
302
+ branch,
303
+ staged: cachedStatus.staged,
304
+ unstaged: cachedStatus.unstaged,
305
+ untracked: cachedStatus.untracked,
306
+ };
307
+ }
308
+
309
+ return { branch, staged: 0, unstaged: 0, untracked: 0 };
310
+ }
311
+
312
+ /**
313
+ * Force refresh git status (call when you know files changed).
314
+ * Serve-stale: keep the last known counts on screen while a background
315
+ * refresh runs, instead of blanking the segment to zeros (footer flicker).
316
+ */
317
+ export function invalidateGitStatus(): void {
318
+ if (cachedStatus) cachedStatus.timestamp = 0; // expire, but keep serving the stale value
319
+ invalidationCounter++; // Increment to invalidate any pending fetches
320
+ }
321
+
322
+ /**
323
+ * Force refresh git branch (call when you know branch might have changed).
324
+ * Serve-stale: keep showing the last known branch until the refresh lands.
325
+ */
326
+ export function invalidateGitBranch(): void {
327
+ if (cachedBranch) cachedBranch.timestamp = 0; // expire, but keep serving the stale value
328
+ branchInvalidationCounter++;
329
+ // The origin remote is repo-scoped, so a branch/cwd change may mean a
330
+ // different repo; drop the host cache so it re-detects.
331
+ cachedRemoteHost = null;
332
+ }
@@ -0,0 +1,67 @@
1
+ import { existsSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { fileURLToPath } from "node:url";
4
+ import { join, resolve } from "node:path";
5
+
6
+ /**
7
+ * Returns the current user's home directory.
8
+ */
9
+ export function getHomeDir(): string {
10
+ return process.env.HOME || process.env.USERPROFILE || homedir();
11
+ }
12
+
13
+ /**
14
+ * Normalizes a path string by expanding tildes and handling file:// URLs.
15
+ */
16
+ export function normalizeAgentDirPath(value: string): string {
17
+ const trimmed = value.trim();
18
+
19
+ if (trimmed.startsWith("file://")) {
20
+ return fileURLToPath(new URL(trimmed));
21
+ }
22
+
23
+ if (trimmed === "~" || trimmed.startsWith("~/") || (process.platform === "win32" && trimmed.startsWith("~\\"))) {
24
+ const withoutTilde = trimmed.substring(1);
25
+ return resolve(getHomeDir(), `.${withoutTilde}`);
26
+ }
27
+
28
+ return trimmed;
29
+ }
30
+
31
+ /**
32
+ * Returns the active pi-coding-agent directory path.
33
+ */
34
+ export function getAgentDir(): string {
35
+ const configured = process.env.PI_CODING_AGENT_DIR;
36
+ if (configured && configured.trim().length > 0) {
37
+ return normalizeAgentDirPath(configured);
38
+ }
39
+ return join(getHomeDir(), ".pi", "agent");
40
+ }
41
+
42
+ /**
43
+ * Returns an absolute path within the pi-coding-agent directory.
44
+ */
45
+ export function getAgentPath(...segments: string[]): string {
46
+ return join(getAgentDir(), ...segments);
47
+ }
48
+
49
+ /**
50
+ * Returns an absolute path within the legacy .pi directory.
51
+ */
52
+ export function getLegacyPiPath(...segments: string[]): string {
53
+ return join(getHomeDir(), ".pi", ...segments);
54
+ }
55
+
56
+ /**
57
+ * Returns a list of candidate directories for storing sessions.
58
+ */
59
+ export function getAgentSessionDirs(): string[] {
60
+ const primary = getAgentPath("sessions");
61
+ const legacy = getLegacyPiPath("sessions");
62
+
63
+ if (primary !== legacy && existsSync(legacy)) {
64
+ return [primary, legacy];
65
+ }
66
+ return [primary];
67
+ }
@@ -0,0 +1,46 @@
1
+ export interface RenderScheduler {
2
+ schedule(delayMs?: number): void;
3
+ cancel(): void;
4
+ }
5
+
6
+ export function createCoalescingTimer(render: () => void, defaultDelayMs: number): RenderScheduler {
7
+ let activeTimer: ReturnType<typeof setTimeout> | null = null;
8
+ let targetTime: number | null = null;
9
+ let runId = 0;
10
+
11
+ return {
12
+ schedule(delayMs = defaultDelayMs) {
13
+ const now = Date.now();
14
+ const nextRunTime = now + delayMs;
15
+
16
+ if (activeTimer !== null && targetTime !== null && targetTime <= nextRunTime) {
17
+ return;
18
+ }
19
+
20
+ if (activeTimer !== null) {
21
+ clearTimeout(activeTimer);
22
+ }
23
+
24
+ targetTime = nextRunTime;
25
+ const currentRunId = ++runId;
26
+
27
+ activeTimer = setTimeout(() => {
28
+ if (currentRunId === runId) {
29
+ activeTimer = null;
30
+ targetTime = null;
31
+ render();
32
+ }
33
+ }, delayMs);
34
+ },
35
+ cancel() {
36
+ if (activeTimer !== null) {
37
+ clearTimeout(activeTimer);
38
+ activeTimer = null;
39
+ targetTime = null;
40
+ }
41
+ runId++;
42
+ }
43
+ };
44
+ }
45
+
46
+ export const createRenderScheduler = createCoalescingTimer;