@buyi1net/pi-toolkit 0.0.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/LICENSE +21 -0
- package/README.md +45 -0
- package/assembler.ts +127 -0
- package/config.ts +201 -0
- package/i18n.ts +693 -0
- package/index.ts +99 -0
- package/menu/items.ts +264 -0
- package/menu/panels.ts +91 -0
- package/menu/settings-list.ts +84 -0
- package/menu/theme.ts +31 -0
- package/menu/toolkit-menu.ts +139 -0
- package/module.ts +156 -0
- package/modules/eyes/chain.ts +173 -0
- package/modules/eyes/config.ts +319 -0
- package/modules/eyes/index.ts +161 -0
- package/modules/eyes/menu.ts +828 -0
- package/modules/eyes/pi-model-backend.ts +245 -0
- package/modules/eyes/resilience.ts +161 -0
- package/modules/eyes/vision-bridge.ts +232 -0
- package/modules/eyes/vision-cache.ts +86 -0
- package/modules/eyes/vision-json.ts +19 -0
- package/modules/eyes/vision-preprocess.ts +192 -0
- package/modules/eyes/vision-probe.ts +21 -0
- package/modules/eyes/vision-prompt.ts +90 -0
- package/modules/eyes/vision-tool.ts +59 -0
- package/modules/eyes/vision-types.ts +33 -0
- package/modules/index.ts +10 -0
- package/modules/subagents/agents/researcher.md +51 -0
- package/modules/subagents/agents/scout.md +40 -0
- package/modules/subagents/agents/worker.md +79 -0
- package/modules/subagents/config.json.example +6 -0
- package/modules/subagents/config.ts +144 -0
- package/modules/subagents/index.ts +91 -0
- package/modules/subagents/menu.ts +388 -0
- package/modules/subagents/src/activity.ts +511 -0
- package/modules/subagents/src/agents.ts +126 -0
- package/modules/subagents/src/command.ts +37 -0
- package/modules/subagents/src/dependencies.ts +246 -0
- package/modules/subagents/src/diagnostics.ts +13 -0
- package/modules/subagents/src/display.ts +94 -0
- package/modules/subagents/src/headless.ts +342 -0
- package/modules/subagents/src/herdr.ts +203 -0
- package/modules/subagents/src/index.ts +2798 -0
- package/modules/subagents/src/inspect-tool.ts +839 -0
- package/modules/subagents/src/launch-config.ts +196 -0
- package/modules/subagents/src/layout-budget.ts +26 -0
- package/modules/subagents/src/list-tool.ts +292 -0
- package/modules/subagents/src/message-tool.ts +808 -0
- package/modules/subagents/src/names.ts +17 -0
- package/modules/subagents/src/pane-layout.ts +49 -0
- package/modules/subagents/src/params.ts +145 -0
- package/modules/subagents/src/renderers.ts +181 -0
- package/modules/subagents/src/result.ts +43 -0
- package/modules/subagents/src/retention.ts +114 -0
- package/modules/subagents/src/route-error.ts +212 -0
- package/modules/subagents/src/routing.ts +235 -0
- package/modules/subagents/src/runtime-registry.ts +159 -0
- package/modules/subagents/src/session.ts +801 -0
- package/modules/subagents/src/status.ts +513 -0
- package/modules/subagents/src/stop-tool.ts +235 -0
- package/modules/subagents/src/subagent-done.ts +590 -0
- package/modules/subagents/src/subagent-tool.ts +1155 -0
- package/modules/subagents/src/surface.ts +355 -0
- package/modules/subagents/src/team-dispatch-tool.ts +219 -0
- package/modules/subagents/src/team.ts +232 -0
- package/modules/subagents/src/tmux.ts +210 -0
- package/modules/subagents/src/tools/safe-bash.ts +72 -0
- package/modules/subagents/src/types.ts +131 -0
- package/modules/tui/adapter/provider-usage.ts +143 -0
- package/modules/tui/config.ts +50 -0
- package/modules/tui/index.ts +120 -0
- package/modules/tui/kernel/pkg/shared/grok-subscription.ts +169 -0
- package/modules/tui/kernel/pkg/shared/official-subscription.ts +237 -0
- package/modules/tui/kernel/pkg/shared/provider-catalog.ts +367 -0
- package/modules/tui/kernel/pkg/shared/provider-contracts.ts +150 -0
- package/modules/tui/kernel/pkg/shared/provider-display.ts +55 -0
- package/modules/tui/kernel/pkg/shared/provider-parsers.ts +171 -0
- package/modules/tui/kernel/pkg/shared/volcengine.ts +191 -0
- package/modules/tui/kernel/pkg/shared/zhipu.ts +149 -0
- package/modules/tui/kernel/pkg/usage-core/index.ts +335 -0
- package/modules/tui/kernel/pkg/usage-core/provider-routes.ts +187 -0
- package/modules/tui/kernel/pkg/usage-node/index.ts +635 -0
- package/modules/tui/kernel/pkg/usage-node/provider-usage.ts +388 -0
- package/modules/tui/kernel/usage-core.ts +2 -0
- package/modules/tui/kernel/usage-node.ts +2 -0
- package/modules/tui/menu.ts +418 -0
- package/modules/tui/plugin/editor.ts +396 -0
- package/modules/tui/plugin/footer.ts +161 -0
- package/modules/tui/plugin/index.ts +30 -0
- package/modules/tui/plugin/lifecycle.ts +637 -0
- package/modules/tui/plugin/package-order.ts +169 -0
- package/modules/tui/plugin/screen-transition.ts +203 -0
- package/modules/tui/plugin/settings-config.ts +297 -0
- package/modules/tui/plugin/status-sources.ts +49 -0
- package/modules/tui/plugin/transition-gate.ts +261 -0
- package/modules/tui/renderer/custom-header.ts +157 -0
- package/modules/tui/renderer/editor.ts +70 -0
- package/modules/tui/renderer/header.ts +72 -0
- package/modules/tui/renderer/icons.ts +149 -0
- package/modules/tui/renderer/pi-installer-logo.ts +194 -0
- package/modules/tui/status/auto-compaction.ts +67 -0
- package/modules/tui/status/project-status.ts +655 -0
- package/modules/tui/status/provider-status.ts +120 -0
- package/modules/tui/status/runtime-status.ts +307 -0
- package/modules/tui/status/session-status.ts +325 -0
- package/modules/tui/status/status-config.ts +95 -0
- package/modules/tui/status/status-segments.ts +263 -0
- package/modules/tui/status/turn-telemetry.ts +466 -0
- package/modules/tui/themes/LICENSE.pi-themes-bundle +21 -0
- package/modules/tui/themes/UPSTREAM.md +7 -0
- package/modules/tui/themes/catppuccin-latte.json +80 -0
- package/modules/tui/themes/catppuccin-mocha.json +79 -0
- package/modules/tui/themes/crimson-noir.json +85 -0
- package/modules/tui/themes/dracula.json +79 -0
- package/modules/tui/themes/everforest-dark.json +85 -0
- package/modules/tui/themes/gruvbox-dark.json +85 -0
- package/modules/tui/themes/gruvbox-light.json +85 -0
- package/modules/tui/themes/matrix.json +85 -0
- package/modules/tui/themes/nord.json +85 -0
- package/modules/tui/themes/one-dark.json +85 -0
- package/modules/tui/themes/rose-pine-dawn.json +85 -0
- package/modules/tui/themes/rose-pine.json +85 -0
- package/modules/tui/themes/solarized-dark.json +85 -0
- package/modules/tui/themes/solarized-light.json +85 -0
- package/modules/tui/themes/tokyo-night-storm.json +79 -0
- package/modules/tui/themes/tokyo-night.json +79 -0
- package/package.json +28 -0
- package/services.ts +38 -0
- package/toolkit.ts +147 -0
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sanitizeQuotaWindowLabel } from "../kernel/usage-core.ts";
|
|
3
|
+
import type { UsageRuntimeState } from "../kernel/usage-node.ts";
|
|
4
|
+
import type { StatusLineSegment } from "./session-status.ts";
|
|
5
|
+
import { sanitizeSingleLine } from "./project-status.ts";
|
|
6
|
+
|
|
7
|
+
const PROVIDER_CORAL = {
|
|
8
|
+
dark: {
|
|
9
|
+
truecolor: "38;2;217;119;87",
|
|
10
|
+
"256color": "38;5;173",
|
|
11
|
+
},
|
|
12
|
+
light: {
|
|
13
|
+
truecolor: "38;2;168;78;51",
|
|
14
|
+
"256color": "38;5;130",
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
function isLightThemeName(name: string | undefined): boolean {
|
|
19
|
+
return /(?:^|[-_])(light|latte|dawn)(?:$|[-_])/.test(name?.toLowerCase() ?? "");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function colorProviderBrand(theme: Theme, text: string): string {
|
|
23
|
+
const palette = isLightThemeName(theme.name) ? PROVIDER_CORAL.light : PROVIDER_CORAL.dark;
|
|
24
|
+
const mode = typeof theme.getColorMode === "function" ? theme.getColorMode() : "truecolor";
|
|
25
|
+
return `\u001b[${palette[mode]}m${text}\u001b[39m`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function formatMoney(amount: number, currency: "CNY" | "USD"): string {
|
|
29
|
+
const value = amount.toLocaleString("en-US", {
|
|
30
|
+
minimumFractionDigits: 2,
|
|
31
|
+
maximumFractionDigits: 2,
|
|
32
|
+
});
|
|
33
|
+
return currency === "CNY" ? `¥${value}` : `$${value}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function remainingColor(percent: number): Parameters<Theme["fg"]>[0] {
|
|
37
|
+
if (percent <= 10) return "error";
|
|
38
|
+
if (percent <= 30) return "warning";
|
|
39
|
+
if (percent < 60) return "accent";
|
|
40
|
+
return "success";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatResetCountdown(resetMs: number | null, now = Date.now()): string {
|
|
44
|
+
if (resetMs === null) return "";
|
|
45
|
+
const remainingMinutes = Math.max(0, Math.ceil((resetMs - now) / 60_000));
|
|
46
|
+
if (remainingMinutes < 60) return `${remainingMinutes}m`;
|
|
47
|
+
const hours = Math.floor(remainingMinutes / 60);
|
|
48
|
+
const minutes = remainingMinutes % 60;
|
|
49
|
+
if (hours < 24) return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
|
50
|
+
const days = Math.floor(hours / 24);
|
|
51
|
+
const remainingHours = hours % 24;
|
|
52
|
+
return remainingHours > 0 ? `${days}d${remainingHours}h` : `${days}d`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface EditorProviderSegments {
|
|
56
|
+
provider: StatusLineSegment;
|
|
57
|
+
balance: StatusLineSegment | null;
|
|
58
|
+
subscription: StatusLineSegment | null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function providerBrand(brandName: string): string {
|
|
62
|
+
return sanitizeSingleLine(brandName);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** 订阅额度窗口全部进入顶边 subscription 段;紧凑形态去掉重置倒计时。 */
|
|
66
|
+
function buildSubscriptionSegment(
|
|
67
|
+
state: UsageRuntimeState,
|
|
68
|
+
theme: Theme,
|
|
69
|
+
now = Date.now(),
|
|
70
|
+
): StatusLineSegment | null {
|
|
71
|
+
const snapshot = state.snapshot;
|
|
72
|
+
if (
|
|
73
|
+
!snapshot ||
|
|
74
|
+
(snapshot.billingMode !== "subscription" && snapshot.billingMode !== "hybrid") ||
|
|
75
|
+
snapshot.windows.length === 0
|
|
76
|
+
) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
const renderWindow = (withReset: boolean): string => snapshot.windows!
|
|
80
|
+
.map((window) => {
|
|
81
|
+
const label = sanitizeQuotaWindowLabel(window.label);
|
|
82
|
+
const percent = theme.bold(`${Math.round(window.remainingPercent)}%`);
|
|
83
|
+
const base = theme.fg(remainingColor(window.remainingPercent), `${label} ${percent}`);
|
|
84
|
+
if (!withReset) return base;
|
|
85
|
+
const reset = formatResetCountdown(window.resetMs, now);
|
|
86
|
+
return reset ? `${base} ${theme.fg("muted", reset)}` : base;
|
|
87
|
+
})
|
|
88
|
+
.join(theme.fg("muted", " · "));
|
|
89
|
+
return {
|
|
90
|
+
id: "subscription",
|
|
91
|
+
text: renderWindow(true),
|
|
92
|
+
compactText: renderWindow(false),
|
|
93
|
+
priority: 3,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function buildEditorProviderSegments(
|
|
98
|
+
state: UsageRuntimeState,
|
|
99
|
+
theme: Theme,
|
|
100
|
+
now = Date.now(),
|
|
101
|
+
): EditorProviderSegments | null {
|
|
102
|
+
if (!state.provider) return null;
|
|
103
|
+
const snapshot = state.status === "ready" ? state.snapshot : null;
|
|
104
|
+
const brand = providerBrand(state.provider.brandName);
|
|
105
|
+
return {
|
|
106
|
+
provider: {
|
|
107
|
+
id: "provider",
|
|
108
|
+
text: brand ? colorProviderBrand(theme, brand) : "",
|
|
109
|
+
priority: 4,
|
|
110
|
+
},
|
|
111
|
+
balance: snapshot?.balance && (snapshot.billingMode === "api" || snapshot.billingMode === "hybrid")
|
|
112
|
+
? {
|
|
113
|
+
id: "balance",
|
|
114
|
+
text: theme.fg("warning", formatMoney(snapshot.balance.amount, snapshot.balance.currency)),
|
|
115
|
+
priority: 3,
|
|
116
|
+
}
|
|
117
|
+
: null,
|
|
118
|
+
subscription: snapshot ? buildSubscriptionSegment(state, theme, now) : null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { readdir, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const CACHE_LIMIT = 32;
|
|
5
|
+
// 暂时不可用的版本命令需要恢复机会,也不能在密集刷新时反复启动进程。
|
|
6
|
+
const VERSION_RETRY_MS = 30_000;
|
|
7
|
+
const RUNTIME_ENV_KEYS = [
|
|
8
|
+
"PATH",
|
|
9
|
+
"Path",
|
|
10
|
+
"PATHEXT",
|
|
11
|
+
"NVM_BIN",
|
|
12
|
+
"NVM_SYMLINK",
|
|
13
|
+
"VOLTA_HOME",
|
|
14
|
+
"ASDF_DIR",
|
|
15
|
+
"MISE_ENV_FILE",
|
|
16
|
+
"PYENV_VERSION",
|
|
17
|
+
"VIRTUAL_ENV",
|
|
18
|
+
] as const;
|
|
19
|
+
|
|
20
|
+
export interface RuntimeStatusSnapshot {
|
|
21
|
+
name: string;
|
|
22
|
+
version?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RuntimeVersionResult {
|
|
26
|
+
stdout: string;
|
|
27
|
+
stderr: string;
|
|
28
|
+
code: number | null;
|
|
29
|
+
killed: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RuntimeVersionQuery = (
|
|
33
|
+
command: string,
|
|
34
|
+
args: readonly string[],
|
|
35
|
+
cwd: string,
|
|
36
|
+
signal: AbortSignal,
|
|
37
|
+
) => Promise<RuntimeVersionResult>;
|
|
38
|
+
|
|
39
|
+
export type RuntimeStatusQuery = (
|
|
40
|
+
cwd: string,
|
|
41
|
+
signal: AbortSignal,
|
|
42
|
+
) => Promise<RuntimeStatusSnapshot | null | undefined>;
|
|
43
|
+
|
|
44
|
+
interface RuntimeDefinition {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
files?: readonly string[];
|
|
48
|
+
folders?: readonly string[];
|
|
49
|
+
extensions?: readonly string[];
|
|
50
|
+
env?: string;
|
|
51
|
+
version?: {
|
|
52
|
+
command: string;
|
|
53
|
+
args: readonly string[];
|
|
54
|
+
pattern: RegExp;
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const RUNTIMES: readonly RuntimeDefinition[] = [
|
|
59
|
+
{ id: "nodejs", name: "Node.js", files: ["package.json", ".nvmrc", ".node-version"], version: { command: "node", args: ["--version"], pattern: /v(\d+\.\d+\.\d+)/ } },
|
|
60
|
+
{ id: "rust", name: "Rust", files: ["Cargo.toml"], version: { command: "rustc", args: ["--version"], pattern: /rustc\s+(\d+\.\d+\.\d+)/ } },
|
|
61
|
+
{ id: "go", name: "Go", files: ["go.mod"], version: { command: "go", args: ["version"], pattern: /go(\d+\.\d+(?:\.\d+)?)/ } },
|
|
62
|
+
{ id: "python", name: "Python", files: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile", ".python-version"], version: { command: process.platform === "win32" ? "python" : "python3", args: ["--version"], pattern: /Python\s+(\d+\.\d+\.\d+)/ } },
|
|
63
|
+
{ id: "ruby", name: "Ruby", files: ["Gemfile", ".ruby-version"], version: { command: "ruby", args: ["--version"], pattern: /ruby\s+(\d+\.\d+\.\d+)/ } },
|
|
64
|
+
{ id: "java", name: "Java", files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"], version: { command: "java", args: ["-version"], pattern: /version\s+"(\d+\.\d+[.\d]*)"/ } },
|
|
65
|
+
{ id: "swift", name: "Swift", files: ["Package.swift"], version: { command: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+(?:\.\d+)?)/ } },
|
|
66
|
+
{ id: "kotlin", name: "Kotlin", files: ["settings.gradle.kts"] },
|
|
67
|
+
{ id: "deno", name: "Deno", files: ["deno.json", "deno.jsonc", "deno.lock"], version: { command: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
|
|
68
|
+
{ id: "bun", name: "Bun", files: ["bun.lock", "bun.lockb"], version: { command: "bun", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
69
|
+
{ id: "php", name: "PHP", files: ["composer.json"], version: { command: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
|
|
70
|
+
{ id: "haskell", name: "Haskell", files: ["stack.yaml", "cabal.project"], extensions: [".cabal"], version: { command: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
71
|
+
{ id: "julia", name: "Julia", files: ["Project.toml", "Manifest.toml"], version: { command: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/i } },
|
|
72
|
+
{ id: "lua", name: "Lua", files: ["stylua.toml", ".luarc.json"], version: { command: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+(?:\.\d+)?)/ } },
|
|
73
|
+
{ id: "elixir", name: "Elixir", files: ["mix.exs"], version: { command: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
|
|
74
|
+
{ id: "erlang", name: "Erlang", files: ["rebar.config", "erlang.mk"] },
|
|
75
|
+
{ id: "gleam", name: "Gleam", files: ["gleam.toml"], version: { command: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/i } },
|
|
76
|
+
{ id: "crystal", name: "Crystal", files: ["shard.yml"], version: { command: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
|
|
77
|
+
{ id: "dart", name: "Dart", files: ["pubspec.yaml"], version: { command: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
|
|
78
|
+
{ id: "nim", name: "Nim", files: ["nim.cfg"], extensions: [".nimble"] },
|
|
79
|
+
{ id: "zig", name: "Zig", files: ["build.zig"], version: { command: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
80
|
+
{ id: "ocaml", name: "OCaml", files: ["dune", "dune-project"], extensions: [".opam"] },
|
|
81
|
+
{ id: "clojure", name: "Clojure", files: ["project.clj", "deps.edn"] },
|
|
82
|
+
{ id: "scala", name: "Scala", files: ["build.sbt"], folders: [".metals"] },
|
|
83
|
+
{ id: "perl", name: "Perl", files: ["Makefile.PL", "cpanfile"] },
|
|
84
|
+
{ id: "r", name: "R", files: ["DESCRIPTION"], extensions: [".Rproj"] },
|
|
85
|
+
{ id: "elm", name: "Elm", files: ["elm.json"] },
|
|
86
|
+
{ id: "haxe", name: "Haxe", files: ["haxelib.json", ".haxerc"] },
|
|
87
|
+
{ id: "vagrant", name: "Vagrant", files: ["Vagrantfile"] },
|
|
88
|
+
{ id: "terraform", name: "Terraform", files: ["main.tf", "variables.tf"], folders: [".terraform"] },
|
|
89
|
+
{ id: "helm", name: "Helm", files: ["Chart.yaml", "helmfile.yaml"] },
|
|
90
|
+
{ id: "solidity", name: "Solidity", extensions: [".sol"] },
|
|
91
|
+
{ id: "fortran", name: "Fortran", files: ["fpm.toml"], extensions: [".f", ".f90", ".f95"] },
|
|
92
|
+
{ id: "mojo", name: "Mojo", extensions: [".mojo"] },
|
|
93
|
+
{ id: "red", name: "Red", extensions: [".red", ".reds"] },
|
|
94
|
+
{ id: "raku", name: "Raku", files: ["META6.json"], extensions: [".raku", ".rakumod"] },
|
|
95
|
+
{ id: "purescript", name: "PureScript", files: ["spago.dhall", "spago.yaml"] },
|
|
96
|
+
{ id: "fennel", name: "Fennel", extensions: [".fnl"] },
|
|
97
|
+
{ id: "odin", name: "Odin", extensions: [".odin"] },
|
|
98
|
+
{ id: "v", name: "V", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
|
|
99
|
+
{ id: "xmake", name: "xmake", files: ["xmake.lua"] },
|
|
100
|
+
{ id: "gradle", name: "Gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
|
|
101
|
+
{ id: "maven", name: "Maven", files: ["pom.xml"] },
|
|
102
|
+
{ id: "cmake", name: "CMake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
|
|
103
|
+
{ id: "meson", name: "Meson", files: ["meson.build"], env: "MESON_DEVENV" },
|
|
104
|
+
{ id: "nix", name: "Nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
|
|
105
|
+
{ id: "guix", name: "Guix", env: "GUIX_ENVIRONMENT" },
|
|
106
|
+
{ id: "conda", name: "Conda", env: "CONDA_DEFAULT_ENV" },
|
|
107
|
+
{ id: "pixi", name: "Pixi", files: ["pixi.toml", "pixi.lock"], env: "PIXI_ENVIRONMENT_NAME" },
|
|
108
|
+
{ id: "spack", name: "Spack", env: "SPACK_ENV" },
|
|
109
|
+
{ id: "pulumi", name: "Pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
|
|
110
|
+
{ id: "typst", name: "Typst", files: ["template.typ"], extensions: [".typ"] },
|
|
111
|
+
{ id: "buf", name: "Buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
|
|
112
|
+
{ id: "dotnet", name: ".NET", files: ["global.json", "Directory.Build.props"], extensions: [".csproj", ".fsproj"] },
|
|
113
|
+
{ id: "cobol", name: "COBOL", extensions: [".cbl", ".cob"] },
|
|
114
|
+
{ id: "cpp", name: "C++", files: ["CMakeLists.txt"], extensions: [".cpp", ".cc", ".cxx"] },
|
|
115
|
+
{ id: "c", name: "C", files: ["Makefile"], extensions: [".c"] },
|
|
116
|
+
] as const;
|
|
117
|
+
|
|
118
|
+
interface RuntimeCacheEntry {
|
|
119
|
+
fingerprint: string;
|
|
120
|
+
value: RuntimeStatusSnapshot;
|
|
121
|
+
retryAt?: number;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function matchesRuntime(
|
|
125
|
+
definition: RuntimeDefinition,
|
|
126
|
+
entries: ReadonlyMap<string, boolean>,
|
|
127
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
128
|
+
): boolean {
|
|
129
|
+
if (definition.env && env[definition.env]) return true;
|
|
130
|
+
if (definition.files?.some((name) => entries.has(name))) return true;
|
|
131
|
+
if (definition.folders?.some((name) => entries.get(name) === true)) return true;
|
|
132
|
+
return definition.extensions?.some((extension) =>
|
|
133
|
+
[...entries.keys()].some((name) => name.endsWith(extension))) ?? false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function runtimeFingerprint(
|
|
137
|
+
cwd: string,
|
|
138
|
+
definition: RuntimeDefinition,
|
|
139
|
+
entryNames: readonly string[],
|
|
140
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
141
|
+
): Promise<string> {
|
|
142
|
+
const parts = [`runtime:${definition.id}`, ...entryNames.slice().sort()];
|
|
143
|
+
for (const name of definition.files ?? []) {
|
|
144
|
+
if (!entryNames.includes(name)) continue;
|
|
145
|
+
try {
|
|
146
|
+
parts.push(`${name}:${(await stat(join(cwd, name))).mtimeMs}`);
|
|
147
|
+
} catch {
|
|
148
|
+
// 列目录和读取元数据之间文件可能被删除,下轮刷新会重新判断。
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (definition.env && env[definition.env]) {
|
|
152
|
+
parts.push(`${definition.env}=${env[definition.env]}`);
|
|
153
|
+
}
|
|
154
|
+
for (const name of RUNTIME_ENV_KEYS) {
|
|
155
|
+
if (env[name]) parts.push(`${name}=${env[name]}`);
|
|
156
|
+
}
|
|
157
|
+
return parts.join("\0");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readVersion(
|
|
161
|
+
definition: RuntimeDefinition,
|
|
162
|
+
cwd: string,
|
|
163
|
+
queryVersion: RuntimeVersionQuery,
|
|
164
|
+
signal: AbortSignal,
|
|
165
|
+
): Promise<string | undefined> {
|
|
166
|
+
if (!definition.version) return undefined;
|
|
167
|
+
try {
|
|
168
|
+
const result = await queryVersion(
|
|
169
|
+
definition.version.command,
|
|
170
|
+
definition.version.args,
|
|
171
|
+
cwd,
|
|
172
|
+
signal,
|
|
173
|
+
);
|
|
174
|
+
signal.throwIfAborted();
|
|
175
|
+
if (result.killed || result.code !== 0) return undefined;
|
|
176
|
+
const match = `${result.stdout}\n${result.stderr}`.match(definition.version.pattern);
|
|
177
|
+
return match?.[1];
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (signal.aborted) throw error;
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 缓存与查询适配器一起归调用方持有,避免新会话或新适配器沿用旧结果。
|
|
185
|
+
export function createRuntimeStatusDetector(
|
|
186
|
+
queryVersion: RuntimeVersionQuery,
|
|
187
|
+
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
188
|
+
now: () => number = Date.now,
|
|
189
|
+
): RuntimeStatusQuery {
|
|
190
|
+
const cache = new Map<string, RuntimeCacheEntry>();
|
|
191
|
+
return async (cwd, signal) => {
|
|
192
|
+
signal.throwIfAborted();
|
|
193
|
+
let directoryEntries;
|
|
194
|
+
try {
|
|
195
|
+
directoryEntries = await readdir(cwd, { withFileTypes: true });
|
|
196
|
+
} catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
signal.throwIfAborted();
|
|
200
|
+
const entries = new Map(directoryEntries.map((entry) => [entry.name, entry.isDirectory()]));
|
|
201
|
+
const definition = RUNTIMES.find((candidate) => matchesRuntime(candidate, entries, env));
|
|
202
|
+
if (!definition) return null;
|
|
203
|
+
|
|
204
|
+
const fingerprint = await runtimeFingerprint(cwd, definition, [...entries.keys()], env);
|
|
205
|
+
signal.throwIfAborted();
|
|
206
|
+
const cached = cache.get(cwd);
|
|
207
|
+
if (cached?.fingerprint === fingerprint && (cached.retryAt === undefined || now() < cached.retryAt)) {
|
|
208
|
+
return cached.value;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const version = await readVersion(definition, cwd, queryVersion, signal);
|
|
212
|
+
const value: RuntimeStatusSnapshot = {
|
|
213
|
+
name: definition.name,
|
|
214
|
+
...(version ? { version } : {}),
|
|
215
|
+
};
|
|
216
|
+
cache.delete(cwd);
|
|
217
|
+
cache.set(cwd, {
|
|
218
|
+
fingerprint,
|
|
219
|
+
value,
|
|
220
|
+
...(definition.version && !version ? { retryAt: now() + VERSION_RETRY_MS } : {}),
|
|
221
|
+
});
|
|
222
|
+
while (cache.size > CACHE_LIMIT) {
|
|
223
|
+
const oldest = cache.keys().next().value;
|
|
224
|
+
if (oldest === undefined) break;
|
|
225
|
+
cache.delete(oldest);
|
|
226
|
+
}
|
|
227
|
+
return value;
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export class RuntimeStatusController {
|
|
232
|
+
private readonly cwd: string;
|
|
233
|
+
private readonly queryRuntimeStatus: RuntimeStatusQuery;
|
|
234
|
+
private readonly debounceMs: number;
|
|
235
|
+
private snapshot: RuntimeStatusSnapshot | null = null;
|
|
236
|
+
private requestRender: (() => void) | undefined;
|
|
237
|
+
private refreshTimer: ReturnType<typeof setTimeout> | undefined;
|
|
238
|
+
private refreshInFlight = false;
|
|
239
|
+
private refreshPending = false;
|
|
240
|
+
private abortController: AbortController | undefined;
|
|
241
|
+
private disposed = false;
|
|
242
|
+
|
|
243
|
+
constructor(cwd: string, queryRuntimeStatus: RuntimeStatusQuery, debounceMs = 120) {
|
|
244
|
+
this.cwd = cwd;
|
|
245
|
+
this.queryRuntimeStatus = queryRuntimeStatus;
|
|
246
|
+
this.debounceMs = debounceMs;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
connect(requestRender: () => void): void {
|
|
250
|
+
if (this.disposed) return;
|
|
251
|
+
this.requestRender = requestRender;
|
|
252
|
+
this.requestRefresh(0);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
disconnect(): void {
|
|
256
|
+
this.requestRender = undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
getSnapshot(): RuntimeStatusSnapshot | null {
|
|
260
|
+
return this.snapshot;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
requestRefresh(delay = this.debounceMs): void {
|
|
264
|
+
if (this.disposed || this.refreshTimer) return;
|
|
265
|
+
if (this.refreshInFlight) {
|
|
266
|
+
this.refreshPending = true;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
this.refreshTimer = setTimeout(() => {
|
|
270
|
+
this.refreshTimer = undefined;
|
|
271
|
+
void this.refresh();
|
|
272
|
+
}, delay);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async refresh(): Promise<void> {
|
|
276
|
+
if (this.disposed || this.refreshInFlight) return;
|
|
277
|
+
this.refreshInFlight = true;
|
|
278
|
+
const abortController = new AbortController();
|
|
279
|
+
this.abortController = abortController;
|
|
280
|
+
try {
|
|
281
|
+
const result = await this.queryRuntimeStatus(this.cwd, abortController.signal);
|
|
282
|
+
if (this.disposed || result === undefined) return;
|
|
283
|
+
if (JSON.stringify(result) !== JSON.stringify(this.snapshot)) {
|
|
284
|
+
this.snapshot = result;
|
|
285
|
+
this.requestRender?.();
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// 查询失败时保留最后一次已识别结果,不让状态行闪烁。
|
|
289
|
+
} finally {
|
|
290
|
+
if (this.abortController === abortController) this.abortController = undefined;
|
|
291
|
+
this.refreshInFlight = false;
|
|
292
|
+
if (this.refreshPending && !this.disposed) {
|
|
293
|
+
this.refreshPending = false;
|
|
294
|
+
this.requestRefresh(0);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
dispose(): void {
|
|
300
|
+
this.disposed = true;
|
|
301
|
+
if (this.refreshTimer) clearTimeout(this.refreshTimer);
|
|
302
|
+
this.refreshTimer = undefined;
|
|
303
|
+
this.abortController?.abort();
|
|
304
|
+
this.abortController = undefined;
|
|
305
|
+
this.disconnect();
|
|
306
|
+
}
|
|
307
|
+
}
|