pi-tui-footer 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +56 -0
- package/extensions/config.ts +196 -0
- package/extensions/editor.ts +295 -0
- package/extensions/footer.ts +344 -0
- package/extensions/fullscreen-scroll.ts +34 -0
- package/extensions/git.ts +151 -0
- package/extensions/header.ts +244 -0
- package/extensions/icons.ts +190 -0
- package/extensions/index.ts +457 -0
- package/extensions/peek.ts +168 -0
- package/extensions/rounded-tools.ts +269 -0
- package/extensions/runtime.ts +174 -0
- package/extensions/session-lifecycle.ts +43 -0
- package/extensions/settings-command.ts +511 -0
- package/extensions/state.ts +104 -0
- package/extensions/telemetry.ts +303 -0
- package/extensions/utils.ts +381 -0
- package/package.json +41 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rounded tool frames (merged port) — rounded frames for tool calls and results.
|
|
3
|
+
*
|
|
4
|
+
* Merged 2026-08 from the standalone local extension rounded-tools.ts (a
|
|
5
|
+
* localized copy of npm:pi-rounded-tools@0.1.3, MIT, by OrionPax,
|
|
6
|
+
* https://github.com/orionpax1997/pi-rounded-tools), integrated into pi-tui
|
|
7
|
+
* (based on https://github.com/OldSuns/pi-open-tui v0.3.7, MIT, by
|
|
8
|
+
* OldSun) and gated behind `roundedTools` in pi-tui config
|
|
9
|
+
* (~/.pi/agent/pi-tui.json, default on).
|
|
10
|
+
*
|
|
11
|
+
* Re-registers the built-in tools (read, write, edit, bash, grep, find, ls)
|
|
12
|
+
* with `renderShell: "self"` and wraps each tool call / result in a frame
|
|
13
|
+
* drawn with Unicode rounded-corner characters (╭ ╮ ╰ ╯ ─ │).
|
|
14
|
+
*
|
|
15
|
+
* No left color bar, no theme matching — just the corners.
|
|
16
|
+
* Border color follows `theme.fg("border", …)` so it adapts to your theme.
|
|
17
|
+
*
|
|
18
|
+
* Implementation note: we don't reimplement the inner rendering — we just
|
|
19
|
+
* call the built-in `renderCall` / `renderResult` and wrap the returned
|
|
20
|
+
* component in a `RoundedFrame`. That way bash's preview, read's syntax
|
|
21
|
+
* highlighting, edit's diff stats, etc. all stay exactly as pi ships them.
|
|
22
|
+
*
|
|
23
|
+
* Disabling registers the plain built-in definitions again, restoring stock
|
|
24
|
+
* rendering in the current session without a /reload.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import {
|
|
29
|
+
createBashToolDefinition,
|
|
30
|
+
createEditToolDefinition,
|
|
31
|
+
createFindToolDefinition,
|
|
32
|
+
createGrepToolDefinition,
|
|
33
|
+
createLsToolDefinition,
|
|
34
|
+
createReadToolDefinition,
|
|
35
|
+
createWriteToolDefinition,
|
|
36
|
+
} from "@earendil-works/pi-coding-agent";
|
|
37
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
38
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
39
|
+
|
|
40
|
+
// ─── Rounded-corner frame ────────────────────────────────────────────────
|
|
41
|
+
//
|
|
42
|
+
// Three modes let call + result stack into one continuous frame:
|
|
43
|
+
// - "closed" → ╭ ── ╮ / │ x │ / ╰ ── ╯ (standalone box)
|
|
44
|
+
// - "open-bottom" → ╭ ── ╮ / │ x │ (top of a stacked frame)
|
|
45
|
+
// - "open-top" → │ x │ / ╰ ── ╯ (bottom of a stacked frame)
|
|
46
|
+
|
|
47
|
+
type FrameMode = "closed" | "open-bottom" | "open-top";
|
|
48
|
+
|
|
49
|
+
class RoundedFrame implements Component {
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly inner: Component,
|
|
52
|
+
private readonly border: (text: string) => string,
|
|
53
|
+
private readonly mode: FrameMode = "closed",
|
|
54
|
+
) {}
|
|
55
|
+
|
|
56
|
+
invalidate(): void {
|
|
57
|
+
this.inner.invalidate?.();
|
|
58
|
+
this.cachedWidth = undefined;
|
|
59
|
+
this.cachedInnerLines = undefined;
|
|
60
|
+
this.cachedLines = undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// pi's built-in tool renderers cache the previously-returned component
|
|
64
|
+
// via `context.lastComponent` and call mutating methods on it
|
|
65
|
+
// (`Text.setText`, `Container.clear`/`addChild`, `invalidate`) so they
|
|
66
|
+
// can re-render in place during streaming. Once we wrap the inner in a
|
|
67
|
+
// RoundedFrame, pi hands our wrapper back to the inner renderer on the
|
|
68
|
+
// next render — but pi's try/catch around `callRenderer(...)` falls
|
|
69
|
+
// back to a plain `createCallFallback()` text node if the inner
|
|
70
|
+
// renderer throws, which would make the border vanish on every
|
|
71
|
+
// Ctrl+O toggle. Forward these methods to the inner so the original
|
|
72
|
+
// renderers work unchanged; calls on a wrapper whose inner lacks the
|
|
73
|
+
// method are no-ops thanks to optional chaining.
|
|
74
|
+
setText(text: string): void {
|
|
75
|
+
(this.inner as any).setText?.(text);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
clear(): void {
|
|
79
|
+
(this.inner as any).clear?.();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
addChild(child: any): void {
|
|
83
|
+
(this.inner as any).addChild?.(child);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
render(width: number): string[] {
|
|
87
|
+
if (width < 4) {
|
|
88
|
+
return this.inner.render(width);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const innerWidth = Math.max(1, width - 4); // │ + space + content + space + │
|
|
92
|
+
const innerLines = this.inner.render(innerWidth);
|
|
93
|
+
|
|
94
|
+
// pi's TUI re-renders every transcript component on every render
|
|
95
|
+
// cycle (each keystroke), but built-in components below us (e.g.
|
|
96
|
+
// `Text`, `Box`) cache their rendered line arrays and return the
|
|
97
|
+
// same array reference when content and width haven't changed.
|
|
98
|
+
// Without a cache here, every keystroke re-runs `visibleWidth()`
|
|
99
|
+
// (ANSI parsing) and string building for every line of every
|
|
100
|
+
// framed tool entry — O(N_session) per keypress, visible as input
|
|
101
|
+
// lag on long sessions.
|
|
102
|
+
//
|
|
103
|
+
// Cache key: `width` plus the array reference returned by
|
|
104
|
+
// `inner.render()`. Built-in Text/Box return the same reference on
|
|
105
|
+
// cache hits and a fresh array whenever their content changes
|
|
106
|
+
// (e.g. Ctrl+O expand), so reference equality is a reliable change
|
|
107
|
+
// signal. `invalidate()` (theme changes etc.) clears the cache
|
|
108
|
+
// explicitly, and so does an actual `width` change.
|
|
109
|
+
if (
|
|
110
|
+
this.cachedLines &&
|
|
111
|
+
this.cachedWidth === width &&
|
|
112
|
+
this.cachedInnerLines === innerLines
|
|
113
|
+
) {
|
|
114
|
+
return this.cachedLines;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const horizontal = "─".repeat(Math.max(0, width - 2));
|
|
118
|
+
const side = this.border("│");
|
|
119
|
+
|
|
120
|
+
const out: string[] = [];
|
|
121
|
+
|
|
122
|
+
if (this.mode !== "open-top") {
|
|
123
|
+
out.push(this.border("╭" + horizontal + "╮"));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (innerLines.length > 0) {
|
|
127
|
+
for (const line of innerLines) {
|
|
128
|
+
const vis = visibleWidth(line);
|
|
129
|
+
const pad = " ".repeat(Math.max(0, innerWidth - vis));
|
|
130
|
+
out.push(side + " " + line + pad + " " + side);
|
|
131
|
+
}
|
|
132
|
+
} else if (this.mode === "closed") {
|
|
133
|
+
// Standalone closed frame with no content: draw a placeholder row
|
|
134
|
+
// so the box still has visual presence. Skipped for open-top /
|
|
135
|
+
// open-bottom since they pair with another half-frame and a fake
|
|
136
|
+
// empty row would show up as a stray blank line in the middle.
|
|
137
|
+
out.push(side + " ".repeat(width - 2) + side);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (this.mode !== "open-bottom") {
|
|
141
|
+
out.push(this.border("╰" + horizontal + "╯"));
|
|
142
|
+
}
|
|
143
|
+
this.cachedWidth = width;
|
|
144
|
+
this.cachedInnerLines = innerLines;
|
|
145
|
+
this.cachedLines = out;
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private cachedWidth: number | undefined;
|
|
150
|
+
private cachedInnerLines: string[] | undefined;
|
|
151
|
+
private cachedLines: string[] | undefined;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const frame = (
|
|
155
|
+
inner: Component,
|
|
156
|
+
theme: { fg: (color: string, text: string) => string },
|
|
157
|
+
mode: FrameMode = "closed",
|
|
158
|
+
colorKey: string = "border",
|
|
159
|
+
): Component => new RoundedFrame(inner, (t) => theme.fg(colorKey, t), mode);
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Pick a border color key based on the tool's runtime state.
|
|
163
|
+
*
|
|
164
|
+
* - still running (`context.isPartial`) → warning (yellow)
|
|
165
|
+
* - finished with an error (`context.isError`) → error (red)
|
|
166
|
+
* - finished successfully → border (theme default)
|
|
167
|
+
*
|
|
168
|
+
* Both `renderCall` and `renderResult` go through this picker, so when
|
|
169
|
+
* they stack into one continuous frame, the borders match. pi re-renders
|
|
170
|
+
* the whole component on every state transition (args streaming → done,
|
|
171
|
+
* partial → final), and `context.isPartial` flips accordingly, so call
|
|
172
|
+
* and result always pick the same color at any given moment.
|
|
173
|
+
*
|
|
174
|
+
* Mirrors pi's own 3-state scheme (`toolPendingBg` / `toolSuccessBg` /
|
|
175
|
+
* `toolErrorBg`) — see `theme.js`.
|
|
176
|
+
*/
|
|
177
|
+
function borderColorFor(
|
|
178
|
+
context: { isPartial?: boolean; isError?: boolean } | undefined,
|
|
179
|
+
): string {
|
|
180
|
+
if (context?.isPartial) return "warning";
|
|
181
|
+
if (context?.isError) return "error";
|
|
182
|
+
return "border";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Helpers ─────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
type ToolDef = ToolDefinition<any, any, any>;
|
|
188
|
+
|
|
189
|
+
const EMPTY_COMPONENT: Component = { render: () => [], invalidate: () => {} };
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Wrap a built-in tool definition in rounded frames.
|
|
193
|
+
*
|
|
194
|
+
* We spread the original definition first so we preserve every metadata
|
|
195
|
+
* field (promptSnippet / promptGuidelines / prepareArguments /
|
|
196
|
+
* constrainedSampling / executionMode / …) — only the renderers and
|
|
197
|
+
* `renderShell` are overwritten. This keeps the tool's LLM-facing prompt
|
|
198
|
+
* data intact, edit's argument pre-processing intact, etc.
|
|
199
|
+
*/
|
|
200
|
+
function wrapBuiltin(def: ToolDef) {
|
|
201
|
+
return {
|
|
202
|
+
...def,
|
|
203
|
+
renderShell: "self" as const,
|
|
204
|
+
renderCall: (args: any, theme: any, context: any) => {
|
|
205
|
+
// Hide our (or any) previous wrapper from the inner renderer so it
|
|
206
|
+
// doesn't treat a RoundedFrame as its own lastComponent — that
|
|
207
|
+
// would either throw (`Text.setText` is missing) or nest frames
|
|
208
|
+
// (`inner = previous RoundedFrame` → we re-wrap it). Forcing the
|
|
209
|
+
// inner to allocate fresh costs one Text/Container per render but
|
|
210
|
+
// keeps every render independent and CC-protect against future
|
|
211
|
+
// tool changes.
|
|
212
|
+
const inner: Component = def.renderCall
|
|
213
|
+
? def.renderCall(args, theme, { ...context, lastComponent: undefined })
|
|
214
|
+
: EMPTY_COMPONENT;
|
|
215
|
+
// During execution pi re-renders every tick (e.g. bash's elapsed
|
|
216
|
+
// timer). Skip the frame then — keeping its borders stable avoids
|
|
217
|
+
// redraw flicker — and only wrap it once the tool settles.
|
|
218
|
+
if (context?.isPartial) return inner;
|
|
219
|
+
return frame(inner, theme, "open-bottom", borderColorFor(context));
|
|
220
|
+
},
|
|
221
|
+
renderResult: (result: any, options: any, theme: any, context: any) => {
|
|
222
|
+
const inner: Component = def.renderResult
|
|
223
|
+
? def.renderResult(result, options, theme, { ...context, lastComponent: undefined })
|
|
224
|
+
: EMPTY_COMPONENT;
|
|
225
|
+
if (context?.isPartial) return inner;
|
|
226
|
+
return frame(inner, theme, "open-top", borderColorFor(context));
|
|
227
|
+
},
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Register the 7 built-in file/bash tools, either with rounded frames
|
|
233
|
+
* (`enabled`) or with their plain stock definitions (`!enabled`, restores
|
|
234
|
+
* original rendering). Registration refreshes the active tool set in the
|
|
235
|
+
* current session, so toggling the setting takes effect immediately.
|
|
236
|
+
*/
|
|
237
|
+
export function registerRoundedTools(pi: ExtensionAPI, enabled: boolean, cwd: string): void {
|
|
238
|
+
const tools: ToolDef[] = [
|
|
239
|
+
createReadToolDefinition(cwd),
|
|
240
|
+
createWriteToolDefinition(cwd),
|
|
241
|
+
createEditToolDefinition(cwd),
|
|
242
|
+
createBashToolDefinition(cwd),
|
|
243
|
+
createGrepToolDefinition(cwd),
|
|
244
|
+
createFindToolDefinition(cwd),
|
|
245
|
+
createLsToolDefinition(cwd),
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
for (const def of tools) {
|
|
249
|
+
pi.registerTool(enabled ? wrapBuiltin(def) : def);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Manager class encapsulating rounded tools registration and lifecycle updates.
|
|
255
|
+
*/
|
|
256
|
+
export class RoundedToolsManager {
|
|
257
|
+
constructor(private readonly pi: ExtensionAPI) {}
|
|
258
|
+
|
|
259
|
+
/** Register initial tools before session_start with fallback cwd. */
|
|
260
|
+
init(enabled: boolean, cwd = process.cwd()): void {
|
|
261
|
+
registerRoundedTools(this.pi, enabled, cwd);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Apply tools with current session cwd. */
|
|
265
|
+
apply(enabled: boolean, cwd: string): void {
|
|
266
|
+
registerRoundedTools(this.pi, enabled, cwd);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
const VERSION_TIMEOUT_MS = 2500;
|
|
8
|
+
|
|
9
|
+
export interface RuntimeInfo {
|
|
10
|
+
name: string;
|
|
11
|
+
version?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface RuntimeDef {
|
|
15
|
+
name: string;
|
|
16
|
+
files: readonly string[];
|
|
17
|
+
folders?: readonly string[];
|
|
18
|
+
extensions?: readonly string[];
|
|
19
|
+
env?: string;
|
|
20
|
+
versionCommand?: { cmd: string; args?: string[]; pattern?: RegExp };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const RUNTIMES: readonly RuntimeDef[] = [
|
|
24
|
+
{ name: "bun", files: ["bun.lock", "bun.lockb"], versionCommand: { cmd: "bun", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
25
|
+
{ name: "nodejs", files: ["package.json", ".nvmrc", ".node-version"], versionCommand: { cmd: "node", args: ["--version"], pattern: /v(\d+\.\d+\.\d+)/ } },
|
|
26
|
+
{ name: "rust", files: ["Cargo.toml"], versionCommand: { cmd: "rustc", args: ["--version"], pattern: /rustc\s+(\d+\.\d+\.\d+)/ } },
|
|
27
|
+
{ name: "go", files: ["go.mod"], versionCommand: { cmd: "go", args: ["version"], pattern: /go(\d+\.\d+\.\d+)/ } },
|
|
28
|
+
{ name: "python", files: ["pyproject.toml", "requirements.txt", "setup.py", "Pipfile", ".python-version"], versionCommand: { cmd: "python3", args: ["--version"], pattern: /Python\s+(\d+\.\d+\.\d+)/ } },
|
|
29
|
+
{ name: "ruby", files: ["Gemfile", ".ruby-version"], versionCommand: { cmd: "ruby", args: ["--version"], pattern: /ruby\s+(\d+\.\d+\.\d+)/ } },
|
|
30
|
+
{ name: "java", files: ["pom.xml", "build.gradle", "build.gradle.kts", ".java-version"], versionCommand: { cmd: "java", args: ["-version"], pattern: /version\s+"(\d+\.\d+[\.\d]*)"/ } },
|
|
31
|
+
{ name: "swift", files: ["Package.swift"], versionCommand: { cmd: "swift", args: ["--version"], pattern: /Swift\s+(\d+\.\d+)/ } },
|
|
32
|
+
{ name: "kotlin", files: ["build.gradle.kts", "settings.gradle.kts"] },
|
|
33
|
+
{ name: "cpp", files: ["CMakeLists.txt", "Makefile"] },
|
|
34
|
+
{ name: "c", files: ["Makefile", "CMakeLists.txt"] },
|
|
35
|
+
{ name: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"], versionCommand: { cmd: "deno", args: ["--version"], pattern: /deno\s+(\d+\.\d+\.\d+)/ } },
|
|
36
|
+
{ name: "php", files: ["composer.json"], versionCommand: { cmd: "php", args: ["--version"], pattern: /PHP\s+(\d+\.\d+\.\d+)/ } },
|
|
37
|
+
{ name: "haskell", files: ["stack.yaml", "cabal.project", ".cabal"], versionCommand: { cmd: "ghc", args: ["--version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
38
|
+
{ name: "julia", files: ["Project.toml", "Manifest.toml"], versionCommand: { cmd: "julia", args: ["--version"], pattern: /julia\s+(\d+\.\d+\.\d+)/ } },
|
|
39
|
+
{ name: "lua", files: ["stylua.toml", ".luarc.json"], versionCommand: { cmd: "lua", args: ["-v"], pattern: /Lua\s+(\d+\.\d+)/ } },
|
|
40
|
+
{ name: "elixir", files: ["mix.exs"], versionCommand: { cmd: "elixir", args: ["--version"], pattern: /Elixir\s+(\d+\.\d+\.\d+)/ } },
|
|
41
|
+
{ name: "erlang", files: ["rebar.config", "erlang.mk"] },
|
|
42
|
+
{ name: "gleam", files: ["gleam.toml"], versionCommand: { cmd: "gleam", args: ["--version"], pattern: /gleam\s+(\d+\.\d+\.\d+)/ } },
|
|
43
|
+
{ name: "crystal", files: ["shard.yml"], versionCommand: { cmd: "crystal", args: ["--version"], pattern: /Crystal\s+(\d+\.\d+\.\d+)/ } },
|
|
44
|
+
{ name: "dart", files: ["pubspec.yaml"], versionCommand: { cmd: "dart", args: ["--version"], pattern: /Dart\s+SDK\s+version:\s+(\d+\.\d+\.\d+)/ } },
|
|
45
|
+
{ name: "nim", files: ["nim.cfg", ".nimble"] },
|
|
46
|
+
{ name: "zig", files: ["build.zig"], versionCommand: { cmd: "zig", args: ["version"], pattern: /(\d+\.\d+\.\d+)/ } },
|
|
47
|
+
{ name: "ocaml", files: [".opam", "dune", "dune-project"] },
|
|
48
|
+
{ name: "clojure", files: ["project.clj", "deps.edn"] },
|
|
49
|
+
{ name: "scala", files: ["build.sbt", ".scala", ".metals"] },
|
|
50
|
+
{ name: "perl", files: ["Makefile.PL", "cpanfile"] },
|
|
51
|
+
{ name: "r", files: [".Rproj", "DESCRIPTION"] },
|
|
52
|
+
{ name: "elm", files: ["elm.json"] },
|
|
53
|
+
{ name: "haxe", files: ["haxelib.json", ".haxerc"] },
|
|
54
|
+
{ name: "vagrant", files: ["Vagrantfile"] },
|
|
55
|
+
{ name: "terraform", files: ["main.tf", "variables.tf"], folders: [".terraform"] },
|
|
56
|
+
{ name: "helm", files: ["Chart.yaml", "helmfile.yaml"] },
|
|
57
|
+
{ name: "solidity", files: [], extensions: [".sol"] },
|
|
58
|
+
{ name: "fortran", files: ["fpm.toml"], extensions: [".f", ".f90", ".f95"] },
|
|
59
|
+
{ name: "mojo", files: [], extensions: [".mojo"] },
|
|
60
|
+
{ name: "red", files: [], extensions: [".red", ".reds"] },
|
|
61
|
+
{ name: "raku", files: ["META6.json"], extensions: [".raku", ".rakumod"] },
|
|
62
|
+
{ name: "purescript", files: ["spago.dhall", "spago.yaml"] },
|
|
63
|
+
{ name: "fennel", files: [], extensions: [".fnl"] },
|
|
64
|
+
{ name: "odin", files: [], extensions: [".odin"] },
|
|
65
|
+
{ name: "v", files: ["v.mod", "vpkg.json"], extensions: [".v"] },
|
|
66
|
+
{ name: "xmake", files: ["xmake.lua"] },
|
|
67
|
+
{ name: "gradle", files: ["build.gradle", "build.gradle.kts"], folders: ["gradle"] },
|
|
68
|
+
{ name: "maven", files: ["pom.xml"] },
|
|
69
|
+
{ name: "cmake", files: ["CMakeLists.txt", "CMakeCache.txt"] },
|
|
70
|
+
{ name: "meson", files: ["meson.build"], env: "MESON_DEVENV" },
|
|
71
|
+
{ name: "nix", files: ["flake.nix", "shell.nix"], env: "IN_NIX_SHELL" },
|
|
72
|
+
{ name: "guix", files: [], env: "GUIX_ENVIRONMENT" },
|
|
73
|
+
{ name: "conda", files: [], env: "CONDA_DEFAULT_ENV" },
|
|
74
|
+
{ name: "pixi", files: ["pixi.toml", "pixi.lock"], env: "PIXI_ENVIRONMENT_NAME" },
|
|
75
|
+
{ name: "spack", files: [], env: "SPACK_ENV" },
|
|
76
|
+
{ name: "pulumi", files: ["Pulumi.yaml", "Pulumi.yml"] },
|
|
77
|
+
{ name: "typst", files: ["template.typ"], extensions: [".typ"] },
|
|
78
|
+
{ name: "buf", files: ["buf.yaml", "buf.gen.yaml", "buf.work.yaml"] },
|
|
79
|
+
{ name: "dotnet", files: [".csproj", ".fsproj", "global.json", "Directory.Build.props"] },
|
|
80
|
+
{ name: "cobol", files: [], extensions: [".cbl", ".cob"] },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
interface CacheEntry {
|
|
84
|
+
fingerprint: string;
|
|
85
|
+
runtime: RuntimeInfo | null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const cache = new Map<string, CacheEntry>();
|
|
89
|
+
const CACHE_MAX = 32;
|
|
90
|
+
|
|
91
|
+
function fingerprint(cwd: string, def: RuntimeDef): string {
|
|
92
|
+
const parts: string[] = [];
|
|
93
|
+
for (const f of def.files) {
|
|
94
|
+
try {
|
|
95
|
+
const stat = statSync(join(cwd, f));
|
|
96
|
+
parts.push(`${f}:${stat.mtimeMs}`);
|
|
97
|
+
} catch { /* ignore */ }
|
|
98
|
+
}
|
|
99
|
+
if (def.extensions || def.folders) {
|
|
100
|
+
try {
|
|
101
|
+
const entries = readdirSync(cwd);
|
|
102
|
+
parts.push(...entries.slice().sort());
|
|
103
|
+
} catch { /* ignore */ }
|
|
104
|
+
}
|
|
105
|
+
if (def.env && process.env[def.env]) {
|
|
106
|
+
parts.push(`${def.env}=${process.env[def.env]}`);
|
|
107
|
+
}
|
|
108
|
+
return parts.join("\0");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function matchesDef(cwd: string, def: RuntimeDef): boolean {
|
|
112
|
+
if (def.env && process.env[def.env]) return true;
|
|
113
|
+
if (def.files.some((f) => existsSync(join(cwd, f)))) return true;
|
|
114
|
+
if (def.folders?.some((f) => existsSync(join(cwd, f)))) return true;
|
|
115
|
+
if (def.extensions) {
|
|
116
|
+
try {
|
|
117
|
+
const entries = readdirSync(cwd);
|
|
118
|
+
if (entries.some((e) => def.extensions!.some((ext) => e.endsWith(ext)))) return true;
|
|
119
|
+
} catch { /* ignore */ }
|
|
120
|
+
}
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function fetchVersion(def: RuntimeDef, cwd: string): Promise<string | undefined> {
|
|
125
|
+
if (!def.versionCommand) return undefined;
|
|
126
|
+
try {
|
|
127
|
+
const { stdout } = await execFileAsync(def.versionCommand.cmd, def.versionCommand.args ?? [], {
|
|
128
|
+
cwd,
|
|
129
|
+
timeout: VERSION_TIMEOUT_MS,
|
|
130
|
+
maxBuffer: 64 * 1024,
|
|
131
|
+
});
|
|
132
|
+
if (def.versionCommand.pattern) {
|
|
133
|
+
const match = stdout.match(def.versionCommand.pattern);
|
|
134
|
+
return match?.[1];
|
|
135
|
+
}
|
|
136
|
+
return stdout.trim() || undefined;
|
|
137
|
+
} catch {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function readRuntimeInfo(cwd: string): Promise<RuntimeInfo | null> {
|
|
143
|
+
for (const def of RUNTIMES) {
|
|
144
|
+
if (!matchesDef(cwd, def)) continue;
|
|
145
|
+
const fp = fingerprint(cwd, def);
|
|
146
|
+
const cacheKey = `${cwd}\0${def.name}`;
|
|
147
|
+
const cached = cache.get(cacheKey);
|
|
148
|
+
if (cached && cached.fingerprint === fp) {
|
|
149
|
+
return cached.runtime;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const key of cache.keys()) {
|
|
153
|
+
if (key === cacheKey || key.startsWith(`${cwd}\0`)) cache.delete(key);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const version = await fetchVersion(def, cwd);
|
|
157
|
+
const info: RuntimeInfo = {
|
|
158
|
+
name: def.name,
|
|
159
|
+
version,
|
|
160
|
+
};
|
|
161
|
+
cache.set(cacheKey, { fingerprint: fp, runtime: info });
|
|
162
|
+
while (cache.size > CACHE_MAX) {
|
|
163
|
+
const oldest = cache.keys().next().value;
|
|
164
|
+
if (oldest === undefined) break;
|
|
165
|
+
cache.delete(oldest);
|
|
166
|
+
}
|
|
167
|
+
return info;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function clearRuntimeCache(): void {
|
|
173
|
+
cache.clear();
|
|
174
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export class SessionLifecycle {
|
|
2
|
+
private current = 0;
|
|
3
|
+
private deferred: Array<() => void> = [];
|
|
4
|
+
private shutDown = false;
|
|
5
|
+
|
|
6
|
+
start(): void {
|
|
7
|
+
this.current++;
|
|
8
|
+
this.shutDown = false;
|
|
9
|
+
this.flushDeferred();
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
shutdown(): void {
|
|
13
|
+
this.shutDown = true;
|
|
14
|
+
this.deferred = [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
isCurrent(generation?: number): boolean {
|
|
18
|
+
if (this.shutDown) return false;
|
|
19
|
+
if (generation === undefined) return true;
|
|
20
|
+
return generation === this.current;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
currentGeneration(): number {
|
|
24
|
+
return this.current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
defer(fn: () => void): void {
|
|
28
|
+
if (this.shutDown) return;
|
|
29
|
+
this.deferred.push(fn);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private flushDeferred(): void {
|
|
33
|
+
const pending = this.deferred;
|
|
34
|
+
this.deferred = [];
|
|
35
|
+
for (const fn of pending) {
|
|
36
|
+
try {
|
|
37
|
+
fn();
|
|
38
|
+
} catch {
|
|
39
|
+
// ponytail: silent fallback — deferred tasks are best-effort
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|