@quandev104/pi-style 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.
- package/CHANGELOG.md +35 -0
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/dist/extensions/pi-style.js +7897 -0
- package/dist/extensions/pi-style.js.map +1 -0
- package/extension-src/pi-style/app/command-service.ts +244 -0
- package/extension-src/pi-style/app/commands.ts +12 -0
- package/extension-src/pi-style/app/config-storage.ts +98 -0
- package/extension-src/pi-style/app/doctor.ts +53 -0
- package/extension-src/pi-style/app/index.ts +322 -0
- package/extension-src/pi-style/app/providers.ts +268 -0
- package/extension-src/pi-style/app/render-scheduler.ts +48 -0
- package/extension-src/pi-style/app/runtime.ts +404 -0
- package/extension-src/pi-style/app/snapshot.ts +15 -0
- package/extension-src/pi-style/domain/capabilities.ts +34 -0
- package/extension-src/pi-style/domain/config-authorization.ts +20 -0
- package/extension-src/pi-style/domain/config-diagnostics.ts +20 -0
- package/extension-src/pi-style/domain/config-diff.ts +30 -0
- package/extension-src/pi-style/domain/config-migrations.ts +52 -0
- package/extension-src/pi-style/domain/config-normalization.ts +567 -0
- package/extension-src/pi-style/domain/config-presets.ts +51 -0
- package/extension-src/pi-style/domain/config-types.ts +115 -0
- package/extension-src/pi-style/domain/providers.ts +37 -0
- package/extension-src/pi-style/domain/status-presets.ts +60 -0
- package/extension-src/pi-style/domain/status-renderer.ts +142 -0
- package/extension-src/pi-style/domain/status.ts +430 -0
- package/extension-src/pi-style/domain/theme.ts +232 -0
- package/extension-src/pi-style/features/.gitkeep +0 -0
- package/extension-src/pi-style/features/editor/index.ts +304 -0
- package/extension-src/pi-style/features/messages/boxed-block.ts +74 -0
- package/extension-src/pi-style/features/messages/index.ts +145 -0
- package/extension-src/pi-style/features/messages/special-blocks.ts +281 -0
- package/extension-src/pi-style/features/startup/index.ts +564 -0
- package/extension-src/pi-style/features/startup/logo.ts +151 -0
- package/extension-src/pi-style/features/status-line/index.ts +319 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +347 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +113 -0
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +67 -0
- package/extension-src/pi-style/features/tools/boxed/find.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +108 -0
- package/extension-src/pi-style/features/tools/boxed/index.ts +64 -0
- package/extension-src/pi-style/features/tools/boxed/ls.ts +65 -0
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +190 -0
- package/extension-src/pi-style/features/tools/boxed/read.ts +204 -0
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +45 -0
- package/extension-src/pi-style/features/tools/boxed/shared.ts +157 -0
- package/extension-src/pi-style/features/tools/boxed/write.ts +89 -0
- package/extension-src/pi-style/features/tools/index.ts +480 -0
- package/extension-src/pi-style/pi/commands.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +195 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +645 -0
- package/extension-src/pi-style/pi/compatibility-registry.ts +329 -0
- package/extension-src/pi-style/pi/config-host.ts +52 -0
- package/extension-src/pi-style/pi/config-session.ts +105 -0
- package/extension-src/pi-style/pi/index.ts +77 -0
- package/extension-src/pi-style/pi/operational-state.ts +29 -0
- package/extension-src/pi-style/pi/session-coordinator.ts +187 -0
- package/extension-src/pi-style/pi/session-usage.ts +62 -0
- package/extension-src/pi-style/pi/startup-resources.ts +70 -0
- package/extension-src/pi-style/shared/.gitkeep +0 -0
- package/extension-src/pi-style/shared/ansi.ts +386 -0
- package/extension-src/pi-style/shared/box.ts +805 -0
- package/extension-src/pi-style/shared/disposable-store.ts +28 -0
- package/extension-src/pi-style/shared/elapsed.ts +88 -0
- package/extension-src/pi-style/shared/render-budget.ts +367 -0
- package/extension-src/pi-style/shared/split-diff.ts +692 -0
- package/extension-src/pi-style/shared/theme-extras.ts +292 -0
- package/package.json +68 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
import { boundedDiagnostics } from "../domain/config-diagnostics.js";
|
|
2
|
+
import { DEFAULT_CONFIG, normalizeConfig, resolveConfigDetailed } from "../domain/config-normalization.js";
|
|
3
|
+
import type { NormalizedPiStyleConfig } from "../domain/config-types.js";
|
|
4
|
+
import type { StartupDetailItem, StartupReason } from "../features/startup/index.js";
|
|
5
|
+
import { createDoctor, type DoctorOperationalState } from "./doctor.js";
|
|
6
|
+
import { PiStyleRuntimeController } from "./runtime.js";
|
|
7
|
+
|
|
8
|
+
export interface StartupResourceDiscovery {
|
|
9
|
+
readonly skillPaths?: readonly string[];
|
|
10
|
+
readonly promptPaths?: readonly string[];
|
|
11
|
+
readonly themePaths?: readonly string[];
|
|
12
|
+
/** Raw system prompt text; word/line counts are computed outside render. */
|
|
13
|
+
readonly systemPrompt?: string;
|
|
14
|
+
/** Context files with pre-computed word/line counts (discovery outside render). */
|
|
15
|
+
readonly contextDetails?: readonly { path: string; words: number; lines: number }[];
|
|
16
|
+
/** Active tools with compact source labels (discovery outside render). */
|
|
17
|
+
readonly toolDetails?: readonly { source: string; name: string }[];
|
|
18
|
+
readonly models?: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function countWords(text: string): number {
|
|
22
|
+
return text.match(/[\p{L}\p{N}_]+/gu)?.length ?? 0;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function countLines(text: string): number {
|
|
26
|
+
if (text.length === 0) return 0;
|
|
27
|
+
const lines = text.split(/\r\n|\r|\n/).length;
|
|
28
|
+
return /\r\n$|\r$|\n$/.test(text) ? lines - 1 : lines;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toStartupResources(resources: StartupResourceDiscovery) {
|
|
32
|
+
const details: StartupDetailItem[] = [];
|
|
33
|
+
if (resources.systemPrompt) {
|
|
34
|
+
const words = countWords(resources.systemPrompt);
|
|
35
|
+
const lines = countLines(resources.systemPrompt);
|
|
36
|
+
if (words > 0 && lines > 0) details.push({ kind: "system", path: "system prompt", words, lines });
|
|
37
|
+
}
|
|
38
|
+
for (const file of resources.contextDetails ?? []) {
|
|
39
|
+
if (file.words > 0 && file.lines > 0) {
|
|
40
|
+
details.push({ kind: "context", path: file.path, words: file.words, lines: file.lines });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
...(resources.promptPaths ? { contextFiles: resources.promptPaths.length } : {}),
|
|
45
|
+
...(resources.themePaths ? { extensions: resources.themePaths.length } : {}),
|
|
46
|
+
...(resources.skillPaths ? { skills: resources.skillPaths.length } : {}),
|
|
47
|
+
...(resources.models !== undefined ? { models: resources.models } : {}),
|
|
48
|
+
...(resources.toolDetails && resources.toolDetails.length > 0
|
|
49
|
+
? { tools: resources.toolDetails.length, toolDetails: resources.toolDetails }
|
|
50
|
+
: {}),
|
|
51
|
+
...(details.length > 0 ? { details } : {}),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ConfigReloadPort {
|
|
56
|
+
load(trusted: boolean): Promise<{
|
|
57
|
+
readonly config?: unknown;
|
|
58
|
+
readonly diagnostics?: readonly import("../domain/config-diagnostics.js").ConfigDiagnostic[];
|
|
59
|
+
readonly sources?: Readonly<Record<string, string>>;
|
|
60
|
+
readonly rawSources?: {
|
|
61
|
+
defaults?: unknown;
|
|
62
|
+
global?: unknown;
|
|
63
|
+
project?: unknown;
|
|
64
|
+
environment?: Record<string, string | undefined>;
|
|
65
|
+
};
|
|
66
|
+
readonly productPolicy?: { corePatchGate: "omitted" | "allow" | "deny" };
|
|
67
|
+
}>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface AppHost {
|
|
71
|
+
readonly mode: "tui" | "rpc" | "json" | "print";
|
|
72
|
+
readonly hasUI: boolean;
|
|
73
|
+
readonly ui?: Parameters<PiStyleRuntimeController["start"]>[0]["ui"];
|
|
74
|
+
readonly cwd?: string;
|
|
75
|
+
readonly model?: { id?: string; name?: string; provider?: string; reasoning?: boolean };
|
|
76
|
+
readonly thinkingLevel?: string;
|
|
77
|
+
readonly getContextUsage?: () => { tokens: number | null; contextWindow: number; percent: number | null } | undefined;
|
|
78
|
+
readonly projectTrusted?: boolean;
|
|
79
|
+
readonly extensionStatusProvider?: () => readonly import("../domain/status.js").ExtensionStatus[] | undefined;
|
|
80
|
+
readonly gitRunner?: import("../domain/providers.js").GitCommandRunner;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PiStyleApp {
|
|
84
|
+
readonly runtime: PiStyleRuntimeController;
|
|
85
|
+
readonly config: NormalizedPiStyleConfig;
|
|
86
|
+
readonly productPolicy: { corePatchGate: "omitted" | "allow" | "deny" };
|
|
87
|
+
sessionStart(ctx: AppHost, reason?: StartupReason, resources?: StartupResourceDiscovery): void;
|
|
88
|
+
setResources(resources: StartupResourceDiscovery): void;
|
|
89
|
+
applySession(patch: unknown): void;
|
|
90
|
+
setProjectTrusted(trusted: boolean): void;
|
|
91
|
+
setOperationalState(state: DoctorOperationalState): void;
|
|
92
|
+
setProductPolicy(policy: { corePatchGate: "omitted" | "allow" | "deny" }): void;
|
|
93
|
+
reload(): Promise<void>;
|
|
94
|
+
doctor(): Readonly<Record<string, unknown>>;
|
|
95
|
+
sessionShutdown(): void;
|
|
96
|
+
update(
|
|
97
|
+
values: import("../domain/status.js").StatusSnapshot,
|
|
98
|
+
kind?: import("./render-scheduler.js").UpdateClass,
|
|
99
|
+
): void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function submittedSessionPaths(value: unknown, prefix = ""): readonly string[] {
|
|
103
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return [];
|
|
104
|
+
return Object.entries(value).flatMap(([key, nested]) => {
|
|
105
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
106
|
+
return [path, ...submittedSessionPaths(nested, path)];
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
function composeSources(
|
|
110
|
+
durable: Readonly<Record<string, string>>,
|
|
111
|
+
resolved: Readonly<Record<string, string>>,
|
|
112
|
+
session: unknown,
|
|
113
|
+
): Readonly<Record<string, string>> {
|
|
114
|
+
const next = { ...durable, ...resolved };
|
|
115
|
+
for (const path of submittedSessionPaths(session)) {
|
|
116
|
+
const effectiveSource = resolved[path];
|
|
117
|
+
if (effectiveSource === undefined) delete next[path];
|
|
118
|
+
else next[path] = effectiveSource;
|
|
119
|
+
}
|
|
120
|
+
return next;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergePatch(base: unknown, patch: unknown): unknown {
|
|
124
|
+
if (patch === undefined) return base;
|
|
125
|
+
if (
|
|
126
|
+
typeof base !== "object" ||
|
|
127
|
+
base === null ||
|
|
128
|
+
Array.isArray(base) ||
|
|
129
|
+
typeof patch !== "object" ||
|
|
130
|
+
patch === null ||
|
|
131
|
+
Array.isArray(patch)
|
|
132
|
+
)
|
|
133
|
+
return patch;
|
|
134
|
+
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
|
|
135
|
+
for (const [key, value] of Object.entries(patch as Record<string, unknown>))
|
|
136
|
+
result[key] = mergePatch(result[key], value);
|
|
137
|
+
return result;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function createPiStyleApp(
|
|
141
|
+
initialConfig?: unknown,
|
|
142
|
+
reloadPort?: ConfigReloadPort,
|
|
143
|
+
onConfigChange?: (config: NormalizedPiStyleConfig) => void,
|
|
144
|
+
): PiStyleApp {
|
|
145
|
+
const runtime = new PiStyleRuntimeController();
|
|
146
|
+
let config = initialConfig === undefined ? DEFAULT_CONFIG : normalizeConfig(initialConfig);
|
|
147
|
+
let diagnostics: readonly import("../domain/config-diagnostics.js").ConfigDiagnostic[] = [];
|
|
148
|
+
let durableDiagnostics: readonly import("../domain/config-diagnostics.js").ConfigDiagnostic[] = [];
|
|
149
|
+
let durableSources: Readonly<Record<string, string>> = {};
|
|
150
|
+
let sources: Readonly<Record<string, string>> = {};
|
|
151
|
+
let trusted = true;
|
|
152
|
+
let resources: StartupResourceDiscovery | undefined;
|
|
153
|
+
let operational: DoctorOperationalState = {};
|
|
154
|
+
let sessionPatch: unknown;
|
|
155
|
+
let rawSources: {
|
|
156
|
+
defaults?: unknown;
|
|
157
|
+
global?: unknown;
|
|
158
|
+
project?: unknown;
|
|
159
|
+
environment?: Record<string, string | undefined>;
|
|
160
|
+
} = { defaults: initialConfig ?? DEFAULT_CONFIG };
|
|
161
|
+
let productPolicy: { corePatchGate: "omitted" | "allow" | "deny" } = { corePatchGate: "omitted" };
|
|
162
|
+
const resolveAll = () => resolveConfigDetailed({ ...rawSources, session: sessionPatch, projectTrusted: trusted });
|
|
163
|
+
const resolveProductPolicy = () => {
|
|
164
|
+
const values = [rawSources.global, trusted ? rawSources.project : undefined, sessionPatch];
|
|
165
|
+
for (const value of values) {
|
|
166
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) continue;
|
|
167
|
+
const compatibility = (value as Record<string, unknown>).compatibility;
|
|
168
|
+
if (
|
|
169
|
+
compatibility &&
|
|
170
|
+
typeof compatibility === "object" &&
|
|
171
|
+
!Array.isArray(compatibility) &&
|
|
172
|
+
Object.hasOwn(compatibility, "allowCorePatches") &&
|
|
173
|
+
(compatibility as Record<string, unknown>).allowCorePatches === false
|
|
174
|
+
)
|
|
175
|
+
return { corePatchGate: "deny" as const };
|
|
176
|
+
}
|
|
177
|
+
return { corePatchGate: "omitted" as const };
|
|
178
|
+
};
|
|
179
|
+
return {
|
|
180
|
+
runtime,
|
|
181
|
+
get config() {
|
|
182
|
+
return config;
|
|
183
|
+
},
|
|
184
|
+
get productPolicy() {
|
|
185
|
+
return productPolicy;
|
|
186
|
+
},
|
|
187
|
+
sessionStart(ctx, reason = "startup", discoveredResources) {
|
|
188
|
+
trusted = ctx.projectTrusted ?? true;
|
|
189
|
+
resources = discoveredResources;
|
|
190
|
+
operational = {
|
|
191
|
+
...operational,
|
|
192
|
+
provider: ctx.extensionStatusProvider
|
|
193
|
+
? { status: "configured", recovery: "provider will be checked on first snapshot" }
|
|
194
|
+
: { status: "unavailable", recovery: "inject a capability-safe provider" },
|
|
195
|
+
installations: {
|
|
196
|
+
status: config.enabled && config.statusLine.enabled ? "active" : "disabled",
|
|
197
|
+
editor: config.enabled && config.editor.enabled ? "active" : "disabled",
|
|
198
|
+
startup: config.enabled && config.startup.mode !== "off" ? "active" : "disabled",
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
const statusProvider = ctx.extensionStatusProvider
|
|
202
|
+
? () => {
|
|
203
|
+
try {
|
|
204
|
+
const result = ctx.extensionStatusProvider?.();
|
|
205
|
+
operational = { ...operational, provider: { status: "available" } };
|
|
206
|
+
return result;
|
|
207
|
+
} catch {
|
|
208
|
+
operational = {
|
|
209
|
+
...operational,
|
|
210
|
+
provider: { status: "unavailable", recovery: "provider threw; retry after recovery" },
|
|
211
|
+
};
|
|
212
|
+
ctx.ui?.notify?.("pi-style extension statuses unavailable; provider recovery required", "warning");
|
|
213
|
+
return undefined;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
: undefined;
|
|
217
|
+
runtime.start({
|
|
218
|
+
mode: ctx.mode,
|
|
219
|
+
hasUI: ctx.hasUI,
|
|
220
|
+
...(ctx.ui ? { ui: ctx.ui } : {}),
|
|
221
|
+
...(ctx.cwd ? { cwd: ctx.cwd } : {}),
|
|
222
|
+
...(ctx.model ? { model: ctx.model } : {}),
|
|
223
|
+
...(ctx.thinkingLevel ? { thinkingLevel: ctx.thinkingLevel } : {}),
|
|
224
|
+
config,
|
|
225
|
+
startupReason: reason,
|
|
226
|
+
...(resources ? { resources: toStartupResources(resources) } : {}),
|
|
227
|
+
...(ctx.getContextUsage ? { getContextUsage: ctx.getContextUsage } : {}),
|
|
228
|
+
...(ctx.projectTrusted !== undefined ? { projectTrusted: ctx.projectTrusted } : {}),
|
|
229
|
+
...(ctx.gitRunner ? { gitRunner: ctx.gitRunner } : {}),
|
|
230
|
+
...(statusProvider ? { extensionStatusProvider: statusProvider } : {}),
|
|
231
|
+
});
|
|
232
|
+
},
|
|
233
|
+
setResources(next) {
|
|
234
|
+
resources = next;
|
|
235
|
+
runtime.current?.updateStartupResources(toStartupResources(next));
|
|
236
|
+
},
|
|
237
|
+
applySession(patch) {
|
|
238
|
+
sessionPatch = mergePatch(sessionPatch, patch);
|
|
239
|
+
const resolved = resolveAll();
|
|
240
|
+
config = resolved.config;
|
|
241
|
+
diagnostics = boundedDiagnostics([...durableDiagnostics, ...resolved.diagnostics]);
|
|
242
|
+
sources = composeSources(durableSources, resolved.sources, sessionPatch);
|
|
243
|
+
productPolicy = resolveProductPolicy();
|
|
244
|
+
operational = {
|
|
245
|
+
...operational,
|
|
246
|
+
authorization: { ...(operational.authorization ?? {}), productCorePatchGate: productPolicy.corePatchGate },
|
|
247
|
+
};
|
|
248
|
+
runtime.current?.configure(config);
|
|
249
|
+
operational = {
|
|
250
|
+
...operational,
|
|
251
|
+
installations: { ...(operational.installations ?? {}), configChange: "reconciled" },
|
|
252
|
+
};
|
|
253
|
+
onConfigChange?.(config);
|
|
254
|
+
},
|
|
255
|
+
setProjectTrusted(nextTrusted) {
|
|
256
|
+
trusted = nextTrusted;
|
|
257
|
+
},
|
|
258
|
+
setOperationalState(state) {
|
|
259
|
+
operational = { ...operational, ...state };
|
|
260
|
+
},
|
|
261
|
+
setProductPolicy(policy) {
|
|
262
|
+
productPolicy = policy;
|
|
263
|
+
operational = {
|
|
264
|
+
...operational,
|
|
265
|
+
authorization: { ...(operational.authorization ?? {}), productCorePatchGate: policy.corePatchGate },
|
|
266
|
+
};
|
|
267
|
+
},
|
|
268
|
+
sessionShutdown() {
|
|
269
|
+
runtime.stop();
|
|
270
|
+
},
|
|
271
|
+
update(values, kind = "coalesced") {
|
|
272
|
+
const active = runtime.current;
|
|
273
|
+
if (!active) return;
|
|
274
|
+
active.update(values);
|
|
275
|
+
active.scheduler.schedule(kind);
|
|
276
|
+
},
|
|
277
|
+
reload() {
|
|
278
|
+
if (!reloadPort) {
|
|
279
|
+
runtime.current?.configure(config);
|
|
280
|
+
return Promise.resolve();
|
|
281
|
+
}
|
|
282
|
+
return reloadPort.load(trusted).then((result) => {
|
|
283
|
+
durableDiagnostics = boundedDiagnostics(result.diagnostics ?? []);
|
|
284
|
+
durableSources = result.sources ?? {};
|
|
285
|
+
diagnostics = durableDiagnostics;
|
|
286
|
+
sources = durableSources;
|
|
287
|
+
if (result.config !== undefined) {
|
|
288
|
+
rawSources = result.rawSources ?? { defaults: result.config };
|
|
289
|
+
productPolicy = resolveProductPolicy();
|
|
290
|
+
const resolved = resolveAll();
|
|
291
|
+
config = resolved.config;
|
|
292
|
+
diagnostics = boundedDiagnostics([...durableDiagnostics, ...resolved.diagnostics]);
|
|
293
|
+
sources = composeSources(durableSources, resolved.sources, sessionPatch);
|
|
294
|
+
onConfigChange?.(config);
|
|
295
|
+
operational = {
|
|
296
|
+
...operational,
|
|
297
|
+
authorization: { ...(operational.authorization ?? {}), productCorePatchGate: productPolicy.corePatchGate },
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
runtime.current?.configure(config);
|
|
301
|
+
onConfigChange?.(config);
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
doctor() {
|
|
305
|
+
return createDoctor({
|
|
306
|
+
config,
|
|
307
|
+
operational,
|
|
308
|
+
diagnostics,
|
|
309
|
+
sources,
|
|
310
|
+
surfaces: {
|
|
311
|
+
status: operational.installations?.status ?? "unknown",
|
|
312
|
+
editor: operational.installations?.editor ?? "unknown",
|
|
313
|
+
startup: operational.installations?.startup ?? "unknown",
|
|
314
|
+
userMessage: operational.compatibility?.userMessage ?? "unknown",
|
|
315
|
+
assistantMessage: operational.compatibility?.assistantMessage ?? "unknown",
|
|
316
|
+
specialBlocks: operational.compatibility?.specialBlocks ?? "unknown",
|
|
317
|
+
tools: operational.compatibility?.tools ?? "unknown",
|
|
318
|
+
},
|
|
319
|
+
});
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
}
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type {
|
|
4
|
+
ContextProvider,
|
|
5
|
+
GitCommandResult,
|
|
6
|
+
GitCommandRunner,
|
|
7
|
+
GitProvider,
|
|
8
|
+
UsageProvider,
|
|
9
|
+
} from "../domain/providers.js";
|
|
10
|
+
import type { ContextSnapshot, GitSnapshot, UsageSnapshot } from "../domain/status.js";
|
|
11
|
+
|
|
12
|
+
const execFileAsync = promisify(execFile);
|
|
13
|
+
const EMPTY_USAGE: UsageSnapshot = Object.freeze({
|
|
14
|
+
inputTokens: 0,
|
|
15
|
+
outputTokens: 0,
|
|
16
|
+
cacheReadTokens: 0,
|
|
17
|
+
cacheWriteTokens: 0,
|
|
18
|
+
streaming: false,
|
|
19
|
+
subscriptionMode: "unknown",
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export class NodeGitCommandRunner implements GitCommandRunner {
|
|
23
|
+
async run(args: readonly string[], cwd: string, timeoutMs: number, signal?: AbortSignal): Promise<GitCommandResult> {
|
|
24
|
+
if (signal?.aborted) return { stdout: "", stderr: "git command aborted", code: 1 };
|
|
25
|
+
try {
|
|
26
|
+
const result = await execFileAsync("git", [...args], {
|
|
27
|
+
cwd,
|
|
28
|
+
timeout: timeoutMs,
|
|
29
|
+
maxBuffer: 1024 * 1024,
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
return { stdout: result.stdout, stderr: result.stderr, code: 0 };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
const failure = error as { stdout?: string; stderr?: string; code?: number; signal?: string };
|
|
35
|
+
return {
|
|
36
|
+
stdout: failure.stdout ?? "",
|
|
37
|
+
stderr: failure.stderr ?? (failure.signal ? `git terminated by ${failure.signal}` : "git command failed"),
|
|
38
|
+
code: failure.code ?? 1,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface GitEntry {
|
|
45
|
+
value: GitSnapshot;
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
retryAt: number;
|
|
48
|
+
generation: number;
|
|
49
|
+
promise?: Promise<GitSnapshot>;
|
|
50
|
+
controller?: AbortController;
|
|
51
|
+
needsRefresh?: boolean;
|
|
52
|
+
resultReady: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface GitProviderStats {
|
|
56
|
+
readonly entries: number;
|
|
57
|
+
readonly inFlight: number;
|
|
58
|
+
readonly refreshes: number;
|
|
59
|
+
readonly disposed: boolean;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface GitProviderClock {
|
|
63
|
+
now(): number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export class CachedGitProvider implements GitProvider {
|
|
67
|
+
private readonly cache = new Map<string, GitEntry>();
|
|
68
|
+
private disposed = false;
|
|
69
|
+
private refreshCount = 0;
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly runner: GitCommandRunner = new NodeGitCommandRunner(),
|
|
72
|
+
private readonly ttlMs = 1000,
|
|
73
|
+
private readonly timeoutMs = 800,
|
|
74
|
+
private readonly maxBackoffMs = 5000,
|
|
75
|
+
private readonly clock: GitProviderClock = { now: () => Date.now() },
|
|
76
|
+
) {}
|
|
77
|
+
get stats(): GitProviderStats {
|
|
78
|
+
return {
|
|
79
|
+
entries: this.cache.size,
|
|
80
|
+
inFlight: [...this.cache.values()].filter((entry) => entry.promise).length,
|
|
81
|
+
refreshes: this.refreshCount,
|
|
82
|
+
disposed: this.disposed,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async get(cwd: string): Promise<GitSnapshot> {
|
|
86
|
+
if (this.disposed) return unavailableGit("provider disposed");
|
|
87
|
+
const now = this.clock.now();
|
|
88
|
+
const current = this.cache.get(cwd);
|
|
89
|
+
if (current && current.expiresAt > now && !current.needsRefresh) return current.value;
|
|
90
|
+
if (current?.promise) return current.resultReady ? current.value : current.promise;
|
|
91
|
+
if (current && current.retryAt > now) return current.value;
|
|
92
|
+
const stale = current?.value;
|
|
93
|
+
const entry: GitEntry = current ?? {
|
|
94
|
+
value: refreshingGit(),
|
|
95
|
+
expiresAt: 0,
|
|
96
|
+
retryAt: 0,
|
|
97
|
+
generation: 0,
|
|
98
|
+
resultReady: false,
|
|
99
|
+
};
|
|
100
|
+
entry.needsRefresh = false;
|
|
101
|
+
entry.resultReady = Boolean(stale);
|
|
102
|
+
entry.value = stale ? { ...stale, refreshing: true } : refreshingGit();
|
|
103
|
+
entry.controller = new AbortController();
|
|
104
|
+
entry.promise = this.refresh(cwd, entry, stale, entry.controller.signal);
|
|
105
|
+
this.cache.set(cwd, entry);
|
|
106
|
+
return stale ? entry.value : entry.promise;
|
|
107
|
+
}
|
|
108
|
+
invalidate(cwd?: string): void {
|
|
109
|
+
const keys = cwd === undefined ? [...this.cache.keys()] : [cwd];
|
|
110
|
+
for (const key of keys) {
|
|
111
|
+
const entry = this.cache.get(key);
|
|
112
|
+
if (!entry) continue;
|
|
113
|
+
entry.generation++;
|
|
114
|
+
entry.expiresAt = 0;
|
|
115
|
+
if (entry.promise) entry.needsRefresh = true;
|
|
116
|
+
else this.cache.delete(key);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
dispose(): void {
|
|
120
|
+
if (this.disposed) return;
|
|
121
|
+
this.disposed = true;
|
|
122
|
+
for (const entry of this.cache.values()) entry.controller?.abort();
|
|
123
|
+
this.cache.clear();
|
|
124
|
+
}
|
|
125
|
+
private async refresh(
|
|
126
|
+
cwd: string,
|
|
127
|
+
entry: GitEntry,
|
|
128
|
+
stale: GitSnapshot | undefined,
|
|
129
|
+
signal: AbortSignal,
|
|
130
|
+
): Promise<GitSnapshot> {
|
|
131
|
+
this.refreshCount++;
|
|
132
|
+
const generation = entry.generation;
|
|
133
|
+
let value: GitSnapshot;
|
|
134
|
+
try {
|
|
135
|
+
const result = await this.runner.run(["status", "--porcelain=v1", "--branch"], cwd, this.timeoutMs, signal);
|
|
136
|
+
if (result.code !== 0) {
|
|
137
|
+
value = stale
|
|
138
|
+
? { ...stale, refreshing: false, error: result.stderr.trim() || "git status failed" }
|
|
139
|
+
: unavailableGit(result.stderr.trim() || "git status failed");
|
|
140
|
+
} else value = parseGitStatus(result.stdout);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
value = stale ? { ...stale, refreshing: false, error: String(error) } : unavailableGit(String(error));
|
|
143
|
+
}
|
|
144
|
+
if (this.disposed || this.cache.get(cwd) !== entry) return value;
|
|
145
|
+
const invalidated = entry.generation !== generation || entry.needsRefresh;
|
|
146
|
+
delete entry.promise;
|
|
147
|
+
delete entry.controller;
|
|
148
|
+
entry.value = value;
|
|
149
|
+
entry.resultReady = true;
|
|
150
|
+
if (invalidated) {
|
|
151
|
+
entry.needsRefresh = false;
|
|
152
|
+
entry.expiresAt = 0;
|
|
153
|
+
void this.get(cwd);
|
|
154
|
+
return value;
|
|
155
|
+
}
|
|
156
|
+
entry.retryAt = value.error ? this.clock.now() + Math.min(this.maxBackoffMs, Math.max(this.ttlMs, 100)) : 0;
|
|
157
|
+
entry.expiresAt = this.clock.now() + this.ttlMs;
|
|
158
|
+
if (entry.needsRefresh) {
|
|
159
|
+
entry.needsRefresh = false;
|
|
160
|
+
void this.get(cwd);
|
|
161
|
+
}
|
|
162
|
+
return value;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function refreshingGit(): GitSnapshot {
|
|
167
|
+
return Object.freeze({ available: false, branch: null, staged: 0, unstaged: 0, untracked: 0, refreshing: true });
|
|
168
|
+
}
|
|
169
|
+
function unavailableGit(error: string): GitSnapshot {
|
|
170
|
+
return Object.freeze({
|
|
171
|
+
available: false,
|
|
172
|
+
branch: null,
|
|
173
|
+
staged: 0,
|
|
174
|
+
unstaged: 0,
|
|
175
|
+
untracked: 0,
|
|
176
|
+
refreshing: false,
|
|
177
|
+
error,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
export function parseGitStatus(output: string): GitSnapshot {
|
|
181
|
+
const lines = output.split("\n").filter(Boolean);
|
|
182
|
+
const header = lines.find((line) => line.startsWith("## "))?.slice(3) ?? "";
|
|
183
|
+
if (!header) return unavailableGit("not a git repository");
|
|
184
|
+
const branch = header.split("...")[0]?.replace(/^HEAD \(no branch\)$/, "detached") ?? null;
|
|
185
|
+
let staged = 0;
|
|
186
|
+
let unstaged = 0;
|
|
187
|
+
let untracked = 0;
|
|
188
|
+
for (const line of lines) {
|
|
189
|
+
if (line.startsWith("## ")) continue;
|
|
190
|
+
if (line.startsWith("??")) {
|
|
191
|
+
untracked++;
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (line.length < 2) continue;
|
|
195
|
+
if (line[0] !== " ") staged++;
|
|
196
|
+
if (line[1] !== " ") unstaged++;
|
|
197
|
+
}
|
|
198
|
+
const ahead = /\[ahead (\d+)\]/.exec(header)?.[1];
|
|
199
|
+
const behind = /\[behind (\d+)\]/.exec(header)?.[1];
|
|
200
|
+
return Object.freeze({
|
|
201
|
+
available: true,
|
|
202
|
+
branch: branch || null,
|
|
203
|
+
staged,
|
|
204
|
+
unstaged,
|
|
205
|
+
untracked,
|
|
206
|
+
...(ahead ? { ahead: Number(ahead) } : {}),
|
|
207
|
+
...(behind ? { behind: Number(behind) } : {}),
|
|
208
|
+
refreshing: false,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export class InMemoryUsageProvider implements UsageProvider {
|
|
213
|
+
private readonly values = new Map<string, UsageSnapshot>();
|
|
214
|
+
private readonly events = new Map<string, Set<string>>();
|
|
215
|
+
private readonly finalized = new Map<string, Set<string>>();
|
|
216
|
+
get(sessionId: string): UsageSnapshot {
|
|
217
|
+
return this.values.get(sessionId) ?? EMPTY_USAGE;
|
|
218
|
+
}
|
|
219
|
+
record(
|
|
220
|
+
sessionId: string,
|
|
221
|
+
usage: Partial<UsageSnapshot>,
|
|
222
|
+
options: { eventId?: string; finalized?: boolean } = {},
|
|
223
|
+
): void {
|
|
224
|
+
if (options.eventId) {
|
|
225
|
+
const ids = this.events.get(sessionId) ?? new Set<string>();
|
|
226
|
+
const finalized = this.finalized.get(sessionId) ?? new Set<string>();
|
|
227
|
+
if (finalized.has(options.eventId) || (ids.has(options.eventId) && !options.finalized)) return;
|
|
228
|
+
ids.add(options.eventId);
|
|
229
|
+
this.events.set(sessionId, ids);
|
|
230
|
+
if (options.finalized) {
|
|
231
|
+
finalized.add(options.eventId);
|
|
232
|
+
this.finalized.set(sessionId, finalized);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
const current = this.get(sessionId);
|
|
236
|
+
const next = {
|
|
237
|
+
...current,
|
|
238
|
+
...usage,
|
|
239
|
+
streaming: options.finalized ? false : (usage.streaming ?? current.streaming),
|
|
240
|
+
};
|
|
241
|
+
this.values.set(sessionId, Object.freeze(next));
|
|
242
|
+
}
|
|
243
|
+
reset(sessionId?: string): void {
|
|
244
|
+
if (sessionId === undefined) {
|
|
245
|
+
this.values.clear();
|
|
246
|
+
this.events.clear();
|
|
247
|
+
this.finalized.clear();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
this.values.delete(sessionId);
|
|
251
|
+
this.events.delete(sessionId);
|
|
252
|
+
this.finalized.delete(sessionId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export class InMemoryContextProvider implements ContextProvider {
|
|
257
|
+
private readonly values = new Map<string, ContextSnapshot>();
|
|
258
|
+
get(sessionId: string): ContextSnapshot | undefined {
|
|
259
|
+
return this.values.get(sessionId);
|
|
260
|
+
}
|
|
261
|
+
set(sessionId: string, context: ContextSnapshot): void {
|
|
262
|
+
this.values.set(sessionId, Object.freeze({ ...context }));
|
|
263
|
+
}
|
|
264
|
+
clear(sessionId?: string): void {
|
|
265
|
+
if (sessionId === undefined) this.values.clear();
|
|
266
|
+
else this.values.delete(sessionId);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
export type UpdateClass = "immediate" | "coalesced" | "deferred" | "delayed-retry";
|
|
2
|
+
export interface SchedulerHost {
|
|
3
|
+
requestRender(): void;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export class RenderScheduler {
|
|
7
|
+
private stopped = false;
|
|
8
|
+
private immediateQueued = false;
|
|
9
|
+
private coalescedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
10
|
+
private deferredTimer: ReturnType<typeof setTimeout> | undefined;
|
|
11
|
+
private retryTimer: ReturnType<typeof setTimeout> | undefined;
|
|
12
|
+
constructor(
|
|
13
|
+
private readonly host: SchedulerHost,
|
|
14
|
+
private readonly generation: number,
|
|
15
|
+
private readonly isCurrent: (generation: number) => boolean = (current) => current === this.generation,
|
|
16
|
+
) {}
|
|
17
|
+
schedule(kind: UpdateClass): void {
|
|
18
|
+
if (this.stopped || !this.isCurrent(this.generation)) return;
|
|
19
|
+
if (kind === "immediate") {
|
|
20
|
+
if (this.immediateQueued) return;
|
|
21
|
+
this.immediateQueued = true;
|
|
22
|
+
queueMicrotask(() => {
|
|
23
|
+
this.immediateQueued = false;
|
|
24
|
+
this.render();
|
|
25
|
+
});
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const target = kind === "coalesced" ? "coalescedTimer" : kind === "deferred" ? "deferredTimer" : "retryTimer";
|
|
29
|
+
if (this[target] !== undefined) return;
|
|
30
|
+
const delay = kind === "coalesced" ? 16 : kind === "deferred" ? 50 : 100;
|
|
31
|
+
this[target] = setTimeout(() => {
|
|
32
|
+
this[target] = undefined;
|
|
33
|
+
this.render();
|
|
34
|
+
}, delay);
|
|
35
|
+
}
|
|
36
|
+
private render(): void {
|
|
37
|
+
if (!this.stopped && this.isCurrent(this.generation)) this.host.requestRender();
|
|
38
|
+
}
|
|
39
|
+
cancel(): void {
|
|
40
|
+
this.stopped = true;
|
|
41
|
+
if (this.coalescedTimer !== undefined) clearTimeout(this.coalescedTimer);
|
|
42
|
+
if (this.deferredTimer !== undefined) clearTimeout(this.deferredTimer);
|
|
43
|
+
if (this.retryTimer !== undefined) clearTimeout(this.retryTimer);
|
|
44
|
+
this.coalescedTimer = undefined;
|
|
45
|
+
this.deferredTimer = undefined;
|
|
46
|
+
this.retryTimer = undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|