@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.
- package/AGENTS.md +68 -0
- package/CHANGELOG.md +724 -0
- package/CONTRIBUTING.md +37 -0
- package/README.md +648 -0
- package/RELEASE.md +117 -0
- package/ROADMAP.md +52 -0
- package/bash-mode/completion-providers.ts +269 -0
- package/bash-mode/completion.ts +416 -0
- package/bash-mode/editor-ghost.ts +40 -0
- package/bash-mode/editor-input.ts +80 -0
- package/bash-mode/editor.ts +437 -0
- package/bash-mode/history.ts +263 -0
- package/bash-mode/shell-session.ts +286 -0
- package/bash-mode/transcript.ts +108 -0
- package/bash-mode/types.ts +80 -0
- package/index.ts +6 -0
- package/package.json +55 -0
- package/queue/store.ts +443 -0
- package/queue/types.ts +54 -0
- package/src/config/custom-items.ts +182 -0
- package/src/config/extension-statuses.ts +51 -0
- package/src/config/layout.ts +60 -0
- package/src/config/parse.ts +127 -0
- package/src/config/powerline-config.ts +18 -0
- package/src/config/presets.ts +245 -0
- package/src/config/primitives.ts +117 -0
- package/src/config/segment-ids.ts +114 -0
- package/src/config/segment-options.ts +128 -0
- package/src/config/settings-patch.ts +26 -0
- package/src/config/types.ts +277 -0
- package/src/core/frontmatter.ts +40 -0
- package/src/editor/autocomplete-chain.ts +41 -0
- package/src/extension/activate.ts +28 -0
- package/src/extension/bash-mode-actions.ts +104 -0
- package/src/extension/commands.ts +268 -0
- package/src/extension/constants.ts +46 -0
- package/src/extension/custom-editor.ts +406 -0
- package/src/extension/git-invalidation.ts +40 -0
- package/src/extension/layout.ts +160 -0
- package/src/extension/menu-views.ts +393 -0
- package/src/extension/powerline-widgets.ts +95 -0
- package/src/extension/prompt-history.ts +219 -0
- package/src/extension/queue-commands.ts +245 -0
- package/src/extension/queue-context.ts +12 -0
- package/src/extension/queue-integration.ts +434 -0
- package/src/extension/segment-context.ts +212 -0
- package/src/extension/session-lifecycle.ts +373 -0
- package/src/extension/settings-io.ts +202 -0
- package/src/extension/shortcuts-config.ts +357 -0
- package/src/extension/shortcuts-router.ts +383 -0
- package/src/extension/skills/inline-invocation.ts +174 -0
- package/src/extension/skills/ook.md +6 -0
- package/src/extension/skills/test.md +6 -0
- package/src/extension/stale-context.ts +10 -0
- package/src/extension/stash-history.ts +103 -0
- package/src/extension/state.ts +159 -0
- package/src/extension/status-line-renderers.ts +222 -0
- package/src/extension/types.ts +97 -0
- package/src/extension/vibe-command.ts +160 -0
- package/src/extension/welcome-control.ts +27 -0
- package/src/extension/welcome-integration.ts +153 -0
- package/src/git/status.ts +332 -0
- package/src/paths/agent-dirs.ts +67 -0
- package/src/render/timer.ts +46 -0
- package/src/segments/core.ts +256 -0
- package/src/segments/custom.ts +114 -0
- package/src/segments/index.ts +3 -0
- package/src/segments/registry.ts +87 -0
- package/src/segments/shared.ts +36 -0
- package/src/segments/system.ts +235 -0
- package/src/segments/usage.ts +178 -0
- package/src/shell/cd-command.ts +190 -0
- package/src/shortcuts/matching.ts +61 -0
- package/src/theme/colors.ts +60 -0
- package/src/theme/icons.ts +175 -0
- package/src/theme/separators.ts +41 -0
- package/src/theme/theme.ts +211 -0
- package/src/tools/graph.ts +75 -0
- package/src/tools/patch.ts +179 -0
- package/src/tools/ripgrep.ts +104 -0
- package/src/usage/context.ts +97 -0
- package/src/usage/ledger.ts +293 -0
- package/src/usage/rates.ts +155 -0
- package/src/welcome/auto-dismiss.ts +43 -0
- package/src/welcome/banner.ts +68 -0
- package/src/welcome/discover.ts +234 -0
- package/src/welcome/format.ts +18 -0
- package/src/welcome/index.ts +5 -0
- package/src/welcome/layout.ts +36 -0
- package/src/welcome/overlay.ts +80 -0
- package/src/welcome/renderer.ts +157 -0
- package/src/welcome/sessions.ts +107 -0
- package/src/welcome/types.ts +41 -0
- package/src/welcome/widgets/graph-widget.ts +25 -0
- package/src/welcome/widgets/index.ts +20 -0
- package/src/welcome/widgets/queue-widget.ts +26 -0
- package/src/welcome/widgets/sessions-widget.ts +23 -0
- package/src/welcome/widgets/shortcuts-widget.ts +17 -0
- package/src/welcome/widgets/system-widget.ts +29 -0
- package/src/working-vibes/generate.ts +144 -0
- package/src/working-vibes/index.ts +24 -0
- package/src/working-vibes/manager.ts +198 -0
- package/src/working-vibes/provider.ts +163 -0
- package/src/working-vibes/storage.ts +357 -0
- package/theme.example.json +24 -0
- 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;
|