@adeildo/pi-kit 4.0.0 → 5.0.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 +1 -1
- package/README.md +42 -5
- package/package.json +4 -3
- package/src/app/builder.ts +30 -6
- package/src/app/feature.ts +51 -0
- package/src/app/host.ts +212 -0
- package/src/app/scope.ts +26 -1
- package/src/ask/client.ts +47 -0
- package/src/contracts/ask.ts +79 -0
- package/src/contracts/screen.ts +71 -0
- package/src/control.ts +59 -0
- package/src/decode.ts +19 -1
- package/src/index.ts +10 -1
- package/src/screen/client.ts +75 -0
- package/src/screen/feature.ts +61 -0
- package/src/screen/model.ts +173 -0
- package/src/screen/view.ts +771 -0
- package/src/settings/files.ts +23 -2
- package/src/settings/setting.ts +19 -2
- package/src/settings/store.ts +91 -21
- package/src/testing.ts +31 -1
package/src/settings/files.ts
CHANGED
|
@@ -34,10 +34,17 @@ export function readSettingsFile(path: string): SettingsFile {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
export function writeSettingsFile(
|
|
37
|
+
export function writeSettingsFile(
|
|
38
|
+
path: string,
|
|
39
|
+
data: SettingsData,
|
|
40
|
+
options: { mode?: number } = {},
|
|
41
|
+
): void {
|
|
38
42
|
mkdirSync(dirname(path), { recursive: true });
|
|
39
43
|
const temporary = `${path}.${process.pid}.tmp`;
|
|
40
|
-
writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`,
|
|
44
|
+
writeFileSync(temporary, `${JSON.stringify(data, null, "\t")}\n`, {
|
|
45
|
+
encoding: "utf8",
|
|
46
|
+
mode: options.mode,
|
|
47
|
+
});
|
|
41
48
|
renameSync(temporary, path);
|
|
42
49
|
}
|
|
43
50
|
|
|
@@ -50,6 +57,20 @@ export function lookup(data: SettingsData, id: string): unknown {
|
|
|
50
57
|
return node;
|
|
51
58
|
}
|
|
52
59
|
|
|
60
|
+
/** Deletes the key, and every object it leaves empty. */
|
|
61
|
+
export function remove(data: SettingsData, id: string): void {
|
|
62
|
+
const [key, ...rest] = id.split(".");
|
|
63
|
+
if (key === undefined || !(key in data)) return;
|
|
64
|
+
if (rest.length === 0) {
|
|
65
|
+
delete data[key];
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const child = data[key];
|
|
69
|
+
if (!isObject(child)) return;
|
|
70
|
+
remove(child, rest.join("."));
|
|
71
|
+
if (Object.keys(child).length === 0) delete data[key];
|
|
72
|
+
}
|
|
73
|
+
|
|
53
74
|
export function assign(data: SettingsData, id: string, value: unknown): void {
|
|
54
75
|
const keys = id.split(".");
|
|
55
76
|
const last = keys.pop();
|
package/src/settings/setting.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
// A setting is declared next to the feature that reads it. The declaration is plain data plus two
|
|
2
2
|
// helpers, so one handle works against any app.
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import type { Control } from "../control.ts";
|
|
3
6
|
import type { Decoder } from "../decode.ts";
|
|
4
7
|
import type { SettingsStore } from "./store.ts";
|
|
5
8
|
|
|
6
|
-
/**
|
|
9
|
+
/** The setting's row on the settings screen. */
|
|
7
10
|
export interface SettingUi {
|
|
8
|
-
|
|
11
|
+
section: string;
|
|
9
12
|
label: string;
|
|
10
13
|
description: string;
|
|
14
|
+
/** Overrides the decoder's. A function runs each time the screen opens. */
|
|
15
|
+
control?: Control | ((ctx: ExtensionContext) => Control);
|
|
16
|
+
/** Applies only after `/reload`. */
|
|
17
|
+
restart?: boolean;
|
|
11
18
|
}
|
|
12
19
|
|
|
13
20
|
export interface SettingDefinition<T> {
|
|
@@ -37,3 +44,13 @@ export function setting<T>(definition: SettingDefinition<T>): Setting<T> {
|
|
|
37
44
|
};
|
|
38
45
|
return handle;
|
|
39
46
|
}
|
|
47
|
+
|
|
48
|
+
export function controlOf(
|
|
49
|
+
entry: Setting<unknown>,
|
|
50
|
+
ctx: ExtensionContext | undefined,
|
|
51
|
+
): Control | undefined {
|
|
52
|
+
const declared = entry.ui?.control;
|
|
53
|
+
if (typeof declared === "function")
|
|
54
|
+
return ctx === undefined ? entry.decoder.control : declared(ctx);
|
|
55
|
+
return declared ?? entry.decoder.control;
|
|
56
|
+
}
|
package/src/settings/store.ts
CHANGED
|
@@ -2,23 +2,43 @@
|
|
|
2
2
|
// first layer with a valid one: project (for settings that accept it, in a trusted project),
|
|
3
3
|
// global, default.
|
|
4
4
|
import { formatProblems } from "../decode.ts";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
assign,
|
|
7
|
+
lookup,
|
|
8
|
+
readSettingsFile,
|
|
9
|
+
remove,
|
|
10
|
+
type SettingsData,
|
|
11
|
+
writeSettingsFile,
|
|
12
|
+
} from "./files.ts";
|
|
6
13
|
import type { Setting } from "./setting.ts";
|
|
7
14
|
|
|
15
|
+
export type Layer = "default" | "global" | "project";
|
|
16
|
+
|
|
8
17
|
type Found<T> = { found: true; value: T } | { found: false };
|
|
9
18
|
|
|
19
|
+
interface Loaded {
|
|
20
|
+
value: unknown;
|
|
21
|
+
layer: Layer;
|
|
22
|
+
global?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
10
25
|
export class SettingsStore {
|
|
11
26
|
readonly globalPath: string;
|
|
12
27
|
#projectPath: string | undefined;
|
|
13
28
|
#known = new Map<string, Setting<unknown>>();
|
|
14
|
-
#
|
|
29
|
+
#loaded = new Map<string, Loaded>();
|
|
15
30
|
#listeners = new Map<string, Set<(value: unknown) => void>>();
|
|
16
|
-
#
|
|
31
|
+
#writeListeners = new Set<() => void>();
|
|
32
|
+
#fresh = false;
|
|
17
33
|
|
|
18
34
|
constructor(globalPath: string) {
|
|
19
35
|
this.globalPath = globalPath;
|
|
20
36
|
}
|
|
21
37
|
|
|
38
|
+
get projectPath(): string | undefined {
|
|
39
|
+
return this.#projectPath;
|
|
40
|
+
}
|
|
41
|
+
|
|
22
42
|
register(settings: readonly Setting<unknown>[]): void {
|
|
23
43
|
for (const entry of settings) {
|
|
24
44
|
const known = this.#known.get(entry.id);
|
|
@@ -26,13 +46,25 @@ export class SettingsStore {
|
|
|
26
46
|
throw new Error(`two settings are named "${entry.id}"`);
|
|
27
47
|
this.#known.set(entry.id, entry);
|
|
28
48
|
}
|
|
29
|
-
this.#
|
|
49
|
+
this.#fresh = false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
known(): Setting<unknown>[] {
|
|
53
|
+
return [...this.#known.values()];
|
|
30
54
|
}
|
|
31
55
|
|
|
56
|
+
/** Throws for a setting no feature declared, which would otherwise read its default forever. */
|
|
32
57
|
get<T>(entry: Setting<T>): T {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
58
|
+
// Only `load` fills #loaded, and always with a value this setting's decoder produced.
|
|
59
|
+
return this.#read(entry).value as T;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
layer(entry: Setting<unknown>): Layer {
|
|
63
|
+
return this.#read(entry).layer;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
hidden(entry: Setting<unknown>): unknown {
|
|
67
|
+
return this.#read(entry).global;
|
|
36
68
|
}
|
|
37
69
|
|
|
38
70
|
listen<T>(entry: Setting<T>, listener: (value: T) => void): () => void {
|
|
@@ -44,24 +76,31 @@ export class SettingsStore {
|
|
|
44
76
|
return () => listeners.delete(untyped);
|
|
45
77
|
}
|
|
46
78
|
|
|
79
|
+
/** Lets other stores on the same files read them again. */
|
|
80
|
+
onWrite(listener: () => void): () => void {
|
|
81
|
+
this.#writeListeners.add(listener);
|
|
82
|
+
return () => this.#writeListeners.delete(listener);
|
|
83
|
+
}
|
|
84
|
+
|
|
47
85
|
/** Reads the files again. Pass the project file only when pi trusts the project. */
|
|
48
86
|
load(projectPath?: string): string[] {
|
|
49
|
-
this.#
|
|
87
|
+
this.#fresh = true;
|
|
50
88
|
this.#projectPath = projectPath;
|
|
51
89
|
const global = readSettingsFile(this.globalPath);
|
|
52
90
|
const project = projectPath === undefined ? undefined : readSettingsFile(projectPath);
|
|
53
91
|
const warnings = [...global.warnings, ...(project?.warnings ?? [])];
|
|
54
92
|
|
|
55
93
|
for (const entry of this.#known.values()) {
|
|
56
|
-
let
|
|
94
|
+
let loaded: Loaded = { value: entry.default, layer: "default" };
|
|
57
95
|
|
|
58
96
|
const fromGlobal = decodeAt(entry, global.data, this.globalPath, warnings);
|
|
59
|
-
if (fromGlobal.found)
|
|
97
|
+
if (fromGlobal.found) loaded = { value: fromGlobal.value, layer: "global" };
|
|
60
98
|
|
|
61
99
|
if (project !== undefined && projectPath !== undefined) {
|
|
62
100
|
if (entry.project) {
|
|
63
101
|
const fromProject = decodeAt(entry, project.data, projectPath, warnings);
|
|
64
|
-
if (fromProject.found)
|
|
102
|
+
if (fromProject.found)
|
|
103
|
+
loaded = { value: fromProject.value, layer: "project", global: loaded.value };
|
|
65
104
|
} else if (lookup(project.data, entry.id) !== undefined) {
|
|
66
105
|
warnings.push(
|
|
67
106
|
`${projectPath}: ${entry.id}: only the global settings file can set this; ignored`,
|
|
@@ -69,7 +108,7 @@ export class SettingsStore {
|
|
|
69
108
|
}
|
|
70
109
|
}
|
|
71
110
|
|
|
72
|
-
this.#update(entry,
|
|
111
|
+
this.#update(entry, loaded);
|
|
73
112
|
}
|
|
74
113
|
|
|
75
114
|
return warnings;
|
|
@@ -86,25 +125,56 @@ export class SettingsStore {
|
|
|
86
125
|
* default, which would freeze them.
|
|
87
126
|
*/
|
|
88
127
|
setAll(entries: readonly (readonly [Setting<unknown>, unknown])[]): string | undefined {
|
|
89
|
-
const
|
|
128
|
+
for (const [entry] of entries) this.#assertKnown(entry);
|
|
129
|
+
const changed = entries.filter(([entry, value]) => !same(this.#globalOf(entry), value));
|
|
90
130
|
if (changed.length === 0) return undefined;
|
|
91
131
|
|
|
92
|
-
|
|
93
|
-
|
|
132
|
+
return this.#edit(this.globalPath, (data) => {
|
|
133
|
+
for (const [entry, value] of changed) assign(data, entry.id, value);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
unset(entry: Setting<unknown>, layer: Exclude<Layer, "default">): string | undefined {
|
|
138
|
+
this.#assertKnown(entry);
|
|
139
|
+
const path = layer === "global" ? this.globalPath : this.#projectPath;
|
|
140
|
+
if (path === undefined) return "no trusted project to change";
|
|
141
|
+
return this.#edit(path, (data) => remove(data, entry.id));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#edit(path: string, change: (data: SettingsData) => void): string | undefined {
|
|
145
|
+
const { data } = readSettingsFile(path);
|
|
146
|
+
change(data);
|
|
94
147
|
try {
|
|
95
|
-
writeSettingsFile(
|
|
148
|
+
writeSettingsFile(path, data);
|
|
96
149
|
} catch (error) {
|
|
97
150
|
return error instanceof Error ? error.message : String(error);
|
|
98
151
|
}
|
|
99
152
|
this.load(this.#projectPath);
|
|
153
|
+
for (const listener of this.#writeListeners) listener();
|
|
100
154
|
return undefined;
|
|
101
155
|
}
|
|
102
156
|
|
|
103
|
-
#
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
157
|
+
#globalOf(entry: Setting<unknown>): unknown {
|
|
158
|
+
const loaded = this.#read(entry);
|
|
159
|
+
return loaded.layer === "project" ? loaded.global : loaded.value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#read(entry: Setting<unknown>): Loaded {
|
|
163
|
+
this.#assertKnown(entry);
|
|
164
|
+
if (!this.#fresh) this.load(this.#projectPath);
|
|
165
|
+
return this.#loaded.get(entry.id) ?? { value: entry.default, layer: "default" };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#assertKnown(entry: Setting<unknown>): void {
|
|
169
|
+
if (this.#known.get(entry.id) !== entry)
|
|
170
|
+
throw new Error(`the setting "${entry.id}" was not declared by any feature of this app`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
#update(entry: Setting<unknown>, loaded: Loaded): void {
|
|
174
|
+
const previous = this.#loaded.get(entry.id)?.value ?? entry.default;
|
|
175
|
+
this.#loaded.set(entry.id, loaded);
|
|
176
|
+
if (same(previous, loaded.value)) return;
|
|
177
|
+
for (const listener of this.#listeners.get(entry.id) ?? []) listener(loaded.value);
|
|
108
178
|
}
|
|
109
179
|
}
|
|
110
180
|
|
package/src/testing.ts
CHANGED
|
@@ -5,17 +5,31 @@ import { join } from "node:path";
|
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
createEventBus,
|
|
8
|
+
createSyntheticSourceInfo,
|
|
8
9
|
type EntryRenderer,
|
|
9
10
|
type EventBus,
|
|
10
11
|
type ExtensionAPI,
|
|
11
12
|
type ExtensionContext,
|
|
12
13
|
type RegisteredCommand,
|
|
13
14
|
type ToolDefinition,
|
|
15
|
+
type ToolInfo,
|
|
14
16
|
} from "@earendil-works/pi-coding-agent";
|
|
15
17
|
|
|
16
|
-
import type { FeatureScope } from "./app/feature.ts";
|
|
18
|
+
import type { FeatureScope, ScreenEntry, ScreenGroup } from "./app/feature.ts";
|
|
17
19
|
import { SettingsStore } from "./settings/store.ts";
|
|
18
20
|
|
|
21
|
+
/** A tool as `getAllTools` reports it, with the fields the callers read. */
|
|
22
|
+
export function toolInfo(name: string, extra: Partial<ToolInfo> = {}): ToolInfo {
|
|
23
|
+
return {
|
|
24
|
+
name,
|
|
25
|
+
description: "",
|
|
26
|
+
parameters: { type: "object" } as ToolInfo["parameters"],
|
|
27
|
+
exposure: "direct",
|
|
28
|
+
sourceInfo: createSyntheticSourceInfo(`<test:${name}>`, { source: "test" }),
|
|
29
|
+
...extra,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
19
33
|
export type FakeHandler = (event: unknown, ctx: ExtensionContext) => unknown;
|
|
20
34
|
type CommandOptions = Omit<RegisteredCommand, "name" | "sourceInfo">;
|
|
21
35
|
|
|
@@ -25,6 +39,8 @@ export interface FakePi {
|
|
|
25
39
|
commands: Map<string, CommandOptions>;
|
|
26
40
|
shortcuts: string[];
|
|
27
41
|
tools: ToolDefinition[];
|
|
42
|
+
/** Every registered tool, as `getAllTools` reports it. */
|
|
43
|
+
allTools: ToolInfo[];
|
|
28
44
|
providers: { name: string; config: unknown }[];
|
|
29
45
|
renderers: Map<string, EntryRenderer>;
|
|
30
46
|
entries: { customType: string; data: unknown }[];
|
|
@@ -43,6 +59,7 @@ export function fakePi(bus: EventBus = createEventBus()): FakePi {
|
|
|
43
59
|
commands,
|
|
44
60
|
shortcuts: [],
|
|
45
61
|
tools: [],
|
|
62
|
+
allTools: [],
|
|
46
63
|
providers: [],
|
|
47
64
|
renderers: new Map(),
|
|
48
65
|
entries: [],
|
|
@@ -67,6 +84,7 @@ export function fakePi(bus: EventBus = createEventBus()): FakePi {
|
|
|
67
84
|
registerCommand: (name: string, options: CommandOptions) => void commands.set(name, options),
|
|
68
85
|
registerShortcut: (shortcut: unknown) => void fake.shortcuts.push(String(shortcut)),
|
|
69
86
|
registerTool: (tool: ToolDefinition) => void fake.tools.push(tool),
|
|
87
|
+
getAllTools: () => fake.allTools,
|
|
70
88
|
registerProvider: (name: unknown, config?: unknown) =>
|
|
71
89
|
void fake.providers.push({ name: String(name), config }),
|
|
72
90
|
registerEntryRenderer: (customType: string, renderer: EntryRenderer) =>
|
|
@@ -85,6 +103,10 @@ export interface FakeScopeOptions {
|
|
|
85
103
|
/** Collects what the feature warned about. */
|
|
86
104
|
notes?: string[];
|
|
87
105
|
settingsPath?: string;
|
|
106
|
+
/** Collects the rows the feature adds to the settings screen. */
|
|
107
|
+
screen?: ScreenEntry[];
|
|
108
|
+
/** Collects the row providers, for the screen to run. */
|
|
109
|
+
screenGroups?: ScreenGroup[];
|
|
88
110
|
}
|
|
89
111
|
|
|
90
112
|
/**
|
|
@@ -95,6 +117,8 @@ export interface FakeScopeOptions {
|
|
|
95
117
|
export function fakeScope(options: FakeScopeOptions = {}): FeatureScope {
|
|
96
118
|
const fake = options.pi ?? fakePi();
|
|
97
119
|
const notes = options.notes ?? [];
|
|
120
|
+
const rows = options.screen ?? [];
|
|
121
|
+
const groups = options.screenGroups ?? [];
|
|
98
122
|
const settings = new SettingsStore(
|
|
99
123
|
options.settingsPath ?? join(tmpdir(), `pi-kit-scope-${process.pid}-${Math.random()}.json`),
|
|
100
124
|
);
|
|
@@ -109,6 +133,12 @@ export function fakeScope(options: FakeScopeOptions = {}): FeatureScope {
|
|
|
109
133
|
onShutdown: (run) => void fake.pi.on("session_shutdown", () => run()),
|
|
110
134
|
warn: (message) => void notes.push(message),
|
|
111
135
|
has: () => false,
|
|
136
|
+
screen: {
|
|
137
|
+
value: (row) => void rows.push({ kind: "value", ...row }),
|
|
138
|
+
action: (row) => void rows.push({ kind: "action", ...row }),
|
|
139
|
+
info: (row) => void rows.push({ kind: "info", ...row }),
|
|
140
|
+
rows: (provider) => void groups.push(provider),
|
|
141
|
+
},
|
|
112
142
|
};
|
|
113
143
|
}
|
|
114
144
|
|