@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.
@@ -34,10 +34,17 @@ export function readSettingsFile(path: string): SettingsFile {
34
34
  }
35
35
  }
36
36
 
37
- export function writeSettingsFile(path: string, data: SettingsData): void {
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`, "utf8");
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();
@@ -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
- /** How a setting shows up on a settings screen. */
9
+ /** The setting's row on the settings screen. */
7
10
  export interface SettingUi {
8
- group: string;
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
+ }
@@ -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 { assign, lookup, readSettingsFile, type SettingsData, writeSettingsFile } from "./files.ts";
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
- #values = new Map<string, unknown>();
29
+ #loaded = new Map<string, Loaded>();
15
30
  #listeners = new Map<string, Set<(value: unknown) => void>>();
16
- #loaded = false;
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.#loaded = false;
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
- if (!this.#loaded) this.load(this.#projectPath);
34
- // Only `load` writes to #values, and always with a value this setting's decoder produced.
35
- return this.#values.has(entry.id) ? (this.#values.get(entry.id) as T) : entry.default;
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.#loaded = true;
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 value = entry.default;
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) value = fromGlobal.value;
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) value = fromProject.value;
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, value);
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 changed = entries.filter(([entry, value]) => !same(this.get(entry), value));
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
- const { data } = readSettingsFile(this.globalPath);
93
- for (const [entry, value] of changed) assign(data, entry.id, value);
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(this.globalPath, data);
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
- #update(entry: Setting<unknown>, value: unknown): void {
104
- const previous = this.#values.has(entry.id) ? this.#values.get(entry.id) : entry.default;
105
- this.#values.set(entry.id, value);
106
- if (same(previous, value)) return;
107
- for (const listener of this.#listeners.get(entry.id) ?? []) listener(value);
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