@adeildo/pi-kit 4.1.0 → 5.1.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/README.md +35 -1
- package/package.json +3 -2
- 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 +83 -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/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/@adeildo/pi-kit)
|
|
4
4
|
|
|
5
|
-
What [`@adeildo/pi-ask-permission`](../ask-permission) and [`@adeildo/pi-harness`](../harness) are built on. It's a library, not a Pi package
|
|
5
|
+
What [`@adeildo/pi-ask-permission`](../ask-permission) and [`@adeildo/pi-harness`](../harness) are built on. It's a library, not a Pi package. The one thing it adds to Pi is the settings screen. It's on npm because the packages need it at runtime, and its API follows what they need, with no promise to anyone else.
|
|
6
6
|
|
|
7
7
|
## An app, and features in it
|
|
8
8
|
|
|
@@ -44,6 +44,7 @@ Each extension a package declares is one app with one or more features. The app'
|
|
|
44
44
|
| `scope.registerCommand` | `pi.registerCommand`. If another feature in the same app already took the name, this one is skipped with a warning. |
|
|
45
45
|
| `scope.onSessionStart` | Runs once the session's settings are loaded. Anything that lasts, like a watcher, a server or a child process, starts here and never in `setup`. |
|
|
46
46
|
| `scope.onShutdown` | Runs once per session, newest first, even when pi fires `session_shutdown` twice. |
|
|
47
|
+
| `scope.screen` | Rows on the settings screen that are not settings. See below. |
|
|
47
48
|
| `scope.warn` | Shows a notification when there's a UI and writes to stderr when there isn't. Messages sent before a session starts are held until it does. |
|
|
48
49
|
| everything else | The scope **is** the extension API, so `scope.registerTool`, `scope.appendEntry`, `scope.events` and the rest work as they always did. |
|
|
49
50
|
|
|
@@ -59,6 +60,39 @@ A setting declared with `project: true` also reads `<project>/.pi/extensions/pi-
|
|
|
59
60
|
|
|
60
61
|
A value that fails to decode is ignored, and the warning names the file and the key.
|
|
61
62
|
|
|
63
|
+
## The settings screen
|
|
64
|
+
|
|
65
|
+
`Alt+S` or `/harness` opens it. Each feature is a tab, and the rows come from two places.
|
|
66
|
+
|
|
67
|
+
A setting with a `ui` gets a row:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export const cursor = setting({
|
|
71
|
+
id: "look.frame.cursor",
|
|
72
|
+
default: "bar",
|
|
73
|
+
decoder: literal("block", "bar", "underline"),
|
|
74
|
+
ui: { section: "Editor", label: "Cursor", description: "The editor's cursor." },
|
|
75
|
+
});
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The decoder picks the editor. `boolean` is a toggle, `literal` a choice, `integer` a number, `stringList` a list. Set `ui.control` to label the options or add presets.
|
|
79
|
+
|
|
80
|
+
Anything else goes through `scope.screen`: `value` for session state, `action` for something to run, `info` for a fact.
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
scope.screen.action({
|
|
84
|
+
id: "judge.test",
|
|
85
|
+
section: "Judge",
|
|
86
|
+
label: "Test the judge",
|
|
87
|
+
description: "Sends one real request.",
|
|
88
|
+
run: (ctx) => probe(ctx),
|
|
89
|
+
});
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
A value typed on the screen goes through the same decoder as the file. Feature options `tab` and `sections` set the tab name and the section order.
|
|
93
|
+
|
|
94
|
+
## Turning a feature off
|
|
95
|
+
|
|
62
96
|
Every feature also gets `features.<id>.enabled`, which defaults to on. A feature that's off never runs `setup`, so it registers nothing and costs nothing. The change takes effect on the next `/reload`.
|
|
63
97
|
|
|
64
98
|
## Events and contracts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adeildo/pi-kit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "The app builder, settings and events that pi-harness packages are built on.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"access": "public"
|
|
28
28
|
},
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@earendil-works/pi-coding-agent": "*"
|
|
30
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
31
|
+
"@earendil-works/pi-tui": "*"
|
|
31
32
|
}
|
|
32
33
|
}
|
package/src/app/builder.ts
CHANGED
|
@@ -4,13 +4,16 @@
|
|
|
4
4
|
// session, and a feature that fails to set up becomes a warning.
|
|
5
5
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { CHANGED } from "../contracts/screen.ts";
|
|
8
|
+
import { boolean, isObject } from "../decode.ts";
|
|
9
|
+
import { settingsScreen } from "../screen/feature.ts";
|
|
8
10
|
import { globalSettingsPath, projectSettingsPath } from "../settings/files.ts";
|
|
9
11
|
import { type Setting, type SettingsScope, setting } from "../settings/setting.ts";
|
|
10
12
|
import { SettingsStore } from "../settings/store.ts";
|
|
11
13
|
import { describe } from "./attribution.ts";
|
|
12
14
|
import { answerClaims, ownerOf } from "./claim.ts";
|
|
13
15
|
import type { Feature } from "./feature.ts";
|
|
16
|
+
import { serveScreen } from "./host.ts";
|
|
14
17
|
import { type AppState, createScope, type Hook } from "./scope.ts";
|
|
15
18
|
|
|
16
19
|
export interface App extends SettingsScope {
|
|
@@ -48,7 +51,6 @@ export function enabledSetting(feature: Feature): Setting<boolean> {
|
|
|
48
51
|
id: `features.${feature.id}.enabled`,
|
|
49
52
|
default: true,
|
|
50
53
|
decoder: boolean,
|
|
51
|
-
ui: { group: "Features", label: feature.id, description: feature.description },
|
|
52
54
|
});
|
|
53
55
|
}
|
|
54
56
|
|
|
@@ -73,11 +75,31 @@ function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature
|
|
|
73
75
|
commands: new Map(),
|
|
74
76
|
starts: [],
|
|
75
77
|
shutdowns: [],
|
|
78
|
+
screen: new Map(),
|
|
79
|
+
screenGroups: new Map(),
|
|
76
80
|
report: (source, message) => deliver(`${options.name}: ${source}: ${message}`),
|
|
77
81
|
};
|
|
78
82
|
const app: App = { name: options.name, settings, has: (id) => mounted.has(id) };
|
|
83
|
+
const running: Feature[] = [];
|
|
79
84
|
|
|
80
85
|
answerClaims(pi, options.name, mounted, () => live);
|
|
86
|
+
serveScreen({
|
|
87
|
+
pi,
|
|
88
|
+
settings,
|
|
89
|
+
features: () => running,
|
|
90
|
+
screen: state.screen,
|
|
91
|
+
screenGroups: state.screenGroups,
|
|
92
|
+
session: () => session,
|
|
93
|
+
isLive: () => live,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Every app writes the same files, so a write by one is read again by the others.
|
|
97
|
+
const store = crypto.randomUUID();
|
|
98
|
+
settings.onWrite(() => pi.events.emit(CHANGED, { source: store }));
|
|
99
|
+
pi.events.on(CHANGED, (data) => {
|
|
100
|
+
if (!live || !isObject(data) || data.source === store) return;
|
|
101
|
+
for (const line of settings.load(settings.projectPath)) state.report("settings", line);
|
|
102
|
+
});
|
|
81
103
|
|
|
82
104
|
// Registered first, so these run ahead of the features' own handlers.
|
|
83
105
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -108,10 +130,9 @@ function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature
|
|
|
108
130
|
session = undefined;
|
|
109
131
|
});
|
|
110
132
|
|
|
111
|
-
// All the switches first
|
|
112
|
-
// still shows up on the settings screen.
|
|
133
|
+
// All the switches first, so one read of the file decides what is on.
|
|
113
134
|
const candidates: { feature: Feature; enabled: Setting<boolean> }[] = [];
|
|
114
|
-
for (const feature of features) {
|
|
135
|
+
for (const feature of [settingsScreen, ...features]) {
|
|
115
136
|
if (candidates.some((candidate) => candidate.feature.id === feature.id)) {
|
|
116
137
|
state.report(feature.id, "was added to this app twice; keeping the first");
|
|
117
138
|
continue;
|
|
@@ -130,13 +151,16 @@ function mount(pi: ExtensionAPI, options: AppOptions, features: readonly Feature
|
|
|
130
151
|
|
|
131
152
|
const owner = ownerOf(pi, feature.id);
|
|
132
153
|
if (owner !== undefined) {
|
|
133
|
-
|
|
154
|
+
// Every app brings the screen, and one is enough.
|
|
155
|
+
if (feature !== settingsScreen)
|
|
156
|
+
state.report(feature.id, `already loaded by ${owner}, so this copy stays off`);
|
|
134
157
|
continue;
|
|
135
158
|
}
|
|
136
159
|
|
|
137
160
|
try {
|
|
138
161
|
feature.setup(createScope(state, feature.id));
|
|
139
162
|
mounted.add(feature.id);
|
|
163
|
+
running.push(feature);
|
|
140
164
|
} catch (error) {
|
|
141
165
|
state.report(feature.id, `failed to set up: ${describe(error)}`);
|
|
142
166
|
}
|
package/src/app/feature.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type {
|
|
|
4
4
|
RegisteredCommand,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
+
import type { Control, Json } from "../control.ts";
|
|
7
8
|
import type { Setting, SettingsScope } from "../settings/setting.ts";
|
|
8
9
|
import type { SettingsStore } from "../settings/store.ts";
|
|
9
10
|
|
|
@@ -16,6 +17,10 @@ export interface Feature {
|
|
|
16
17
|
id: string;
|
|
17
18
|
/** One line for the settings screen. */
|
|
18
19
|
description: string;
|
|
20
|
+
/** The settings tab. Features with the same tab share it. Defaults to the id. */
|
|
21
|
+
tab?: string;
|
|
22
|
+
/** Section order on the tab. Sections not listed come after. */
|
|
23
|
+
sections?: readonly string[];
|
|
19
24
|
settings?: readonly Setting<unknown>[];
|
|
20
25
|
/** Registers handlers, commands and providers. Starts nothing: that goes in `onSessionStart`. */
|
|
21
26
|
setup(scope: FeatureScope): void;
|
|
@@ -37,6 +42,52 @@ export interface FeatureScope extends ExtensionAPI, SettingsScope {
|
|
|
37
42
|
warn(message: string): void;
|
|
38
43
|
/** True when this app runs a feature with that id. */
|
|
39
44
|
has(featureId: string): boolean;
|
|
45
|
+
readonly screen: ScreenRows;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface ScreenRow {
|
|
49
|
+
id: string;
|
|
50
|
+
section: string;
|
|
51
|
+
label: string;
|
|
52
|
+
description: string;
|
|
53
|
+
/** How deep the row sits under its section. Each step indents two columns. */
|
|
54
|
+
indent?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface ScreenValue extends ScreenRow {
|
|
58
|
+
control: Control | ((ctx: ExtensionContext) => Control);
|
|
59
|
+
get(ctx: ExtensionContext): Json;
|
|
60
|
+
/** Returns why the value was refused. */
|
|
61
|
+
set(value: Json, ctx: ExtensionContext): string | undefined;
|
|
62
|
+
/** Where the value applies, when that is not this session. */
|
|
63
|
+
meta?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ScreenAction extends ScreenRow {
|
|
67
|
+
text?(ctx: ExtensionContext): string;
|
|
68
|
+
confirm?: string;
|
|
69
|
+
/** Returned text opens in the screen. A throw shows as an error. */
|
|
70
|
+
run(ctx: ExtensionContext): string | undefined | Promise<string | undefined>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScreenInfo extends ScreenRow {
|
|
74
|
+
text(ctx: ExtensionContext): string;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export type ScreenEntry =
|
|
78
|
+
| ({ kind: "value" } & ScreenValue)
|
|
79
|
+
| ({ kind: "action" } & ScreenAction)
|
|
80
|
+
| ({ kind: "info" } & ScreenInfo);
|
|
81
|
+
|
|
82
|
+
/** Rows a feature builds when the screen opens, for what is only known then. */
|
|
83
|
+
export type ScreenGroup = (ctx: ExtensionContext) => ScreenEntry[];
|
|
84
|
+
|
|
85
|
+
export interface ScreenRows {
|
|
86
|
+
value(row: ScreenValue): void;
|
|
87
|
+
action(row: ScreenAction): void;
|
|
88
|
+
info(row: ScreenInfo): void;
|
|
89
|
+
/** One call, many rows: the provider runs every time the screen opens. */
|
|
90
|
+
rows(provider: ScreenGroup): void;
|
|
40
91
|
}
|
|
41
92
|
|
|
42
93
|
export function defineFeature(feature: Feature): Feature {
|
package/src/app/host.ts
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Each app answers the screen for the features it runs. A change goes through the setting's own
|
|
2
|
+
// decoder, like a value read from the file.
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
APPLY,
|
|
7
|
+
type ApplyRequest,
|
|
8
|
+
DONE,
|
|
9
|
+
LIST,
|
|
10
|
+
type RowView,
|
|
11
|
+
RUN,
|
|
12
|
+
type RunDone,
|
|
13
|
+
type RunRequest,
|
|
14
|
+
type TabView,
|
|
15
|
+
} from "../contracts/screen.ts";
|
|
16
|
+
import type { Json } from "../control.ts";
|
|
17
|
+
import { formatProblems, isObject } from "../decode.ts";
|
|
18
|
+
import { controlOf, type Setting } from "../settings/setting.ts";
|
|
19
|
+
import type { SettingsStore } from "../settings/store.ts";
|
|
20
|
+
import type { Feature, ScreenEntry, ScreenGroup } from "./feature.ts";
|
|
21
|
+
|
|
22
|
+
export interface Host {
|
|
23
|
+
pi: ExtensionAPI;
|
|
24
|
+
settings: SettingsStore;
|
|
25
|
+
features: () => readonly Feature[];
|
|
26
|
+
screen: ReadonlyMap<string, ScreenEntry[]>;
|
|
27
|
+
/** Rows a feature builds when the screen opens. */
|
|
28
|
+
screenGroups: ReadonlyMap<string, ScreenGroup[]>;
|
|
29
|
+
session: () => ExtensionContext | undefined;
|
|
30
|
+
isLive: () => boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function serveScreen(host: Host): void {
|
|
34
|
+
host.pi.events.on(LIST, (data) => {
|
|
35
|
+
if (!host.isLive() || !isObject(data) || !Array.isArray(data.tabs)) return;
|
|
36
|
+
const ctx = host.session();
|
|
37
|
+
for (const feature of host.features()) data.tabs.push(tabOf(host, feature, ctx));
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
host.pi.events.on(APPLY, (data) => {
|
|
41
|
+
const request = asApply(data);
|
|
42
|
+
if (request === undefined || !host.isLive()) return;
|
|
43
|
+
const feature = host.features().find((entry) => entry.id === request.feature);
|
|
44
|
+
if (feature === undefined) return;
|
|
45
|
+
request.answer = { error: apply(host, feature, request) };
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
host.pi.events.on(RUN, (data) => {
|
|
49
|
+
const request = asRun(data);
|
|
50
|
+
if (request === undefined || !host.isLive()) return;
|
|
51
|
+
if (!host.features().some((entry) => entry.id === request.feature)) return;
|
|
52
|
+
const ctx = host.session();
|
|
53
|
+
const row = findRow(host, request.feature, request.id, ctx);
|
|
54
|
+
if (row === undefined) return;
|
|
55
|
+
|
|
56
|
+
if (row.kind !== "action" || ctx === undefined) {
|
|
57
|
+
request.answer = { error: "nothing to run" };
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
request.answer = {};
|
|
61
|
+
const id = request.request;
|
|
62
|
+
void runAction(row, ctx).then((done) => host.pi.events.emit(DONE, { request: id, ...done }));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function tabOf(host: Host, feature: Feature, ctx: ExtensionContext | undefined): TabView {
|
|
67
|
+
const rows: RowView[] = [];
|
|
68
|
+
for (const entry of feature.settings ?? []) {
|
|
69
|
+
const row = settingRow(host.settings, feature, entry, ctx);
|
|
70
|
+
if (row !== undefined) rows.push(row);
|
|
71
|
+
}
|
|
72
|
+
if (ctx !== undefined) {
|
|
73
|
+
for (const entry of entriesOf(host, feature.id, ctx)) rows.push(screenRow(feature, entry, ctx));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const sections = [...(feature.sections ?? [])];
|
|
77
|
+
for (const row of rows) if (!sections.includes(row.section)) sections.push(row.section);
|
|
78
|
+
return { title: feature.tab ?? capitalize(feature.id), sections, rows };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function settingRow(
|
|
82
|
+
settings: SettingsStore,
|
|
83
|
+
feature: Feature,
|
|
84
|
+
entry: Setting<unknown>,
|
|
85
|
+
ctx: ExtensionContext | undefined,
|
|
86
|
+
): RowView | undefined {
|
|
87
|
+
const ui = entry.ui;
|
|
88
|
+
if (ui === undefined) return undefined;
|
|
89
|
+
|
|
90
|
+
const control = controlOf(entry, ctx);
|
|
91
|
+
const value = toJson(settings.get(entry));
|
|
92
|
+
const layer = settings.layer(entry);
|
|
93
|
+
const row: RowView = {
|
|
94
|
+
feature: feature.id,
|
|
95
|
+
id: entry.id,
|
|
96
|
+
kind: control === undefined ? "info" : "setting",
|
|
97
|
+
section: ui.section,
|
|
98
|
+
label: ui.label,
|
|
99
|
+
description: ui.description,
|
|
100
|
+
value,
|
|
101
|
+
layer,
|
|
102
|
+
fallback: toJson(entry.default),
|
|
103
|
+
};
|
|
104
|
+
if (control === undefined) row.text = JSON.stringify(value);
|
|
105
|
+
else row.control = control;
|
|
106
|
+
if (layer === "project") row.hidden = toJson(settings.hidden(entry));
|
|
107
|
+
if (ui.restart === true) row.restart = true;
|
|
108
|
+
return row;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function screenRow(feature: Feature, entry: ScreenEntry, ctx: ExtensionContext): RowView {
|
|
112
|
+
const row: RowView = {
|
|
113
|
+
feature: feature.id,
|
|
114
|
+
id: entry.id,
|
|
115
|
+
kind: entry.kind,
|
|
116
|
+
section: entry.section,
|
|
117
|
+
label: entry.label,
|
|
118
|
+
description: entry.description,
|
|
119
|
+
};
|
|
120
|
+
if (entry.indent !== undefined) row.indent = entry.indent;
|
|
121
|
+
switch (entry.kind) {
|
|
122
|
+
case "value":
|
|
123
|
+
row.control = typeof entry.control === "function" ? entry.control(ctx) : entry.control;
|
|
124
|
+
row.value = entry.get(ctx);
|
|
125
|
+
if (entry.meta !== undefined) row.meta = entry.meta;
|
|
126
|
+
break;
|
|
127
|
+
case "action": {
|
|
128
|
+
const text = entry.text?.(ctx);
|
|
129
|
+
if (text !== undefined) row.text = text;
|
|
130
|
+
if (entry.confirm !== undefined) row.confirm = entry.confirm;
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
133
|
+
case "info":
|
|
134
|
+
row.text = entry.text(ctx);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
return row;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function apply(host: Host, feature: Feature, request: ApplyRequest): string | undefined {
|
|
141
|
+
const entry = feature.settings?.find((candidate) => candidate.id === request.id);
|
|
142
|
+
if (entry !== undefined) {
|
|
143
|
+
if (request.op === "unset") {
|
|
144
|
+
const layer = host.settings.layer(entry);
|
|
145
|
+
return layer === "default" ? undefined : host.settings.unset(entry, layer);
|
|
146
|
+
}
|
|
147
|
+
const decoded = entry.decoder.decode(request.value, entry.id);
|
|
148
|
+
if (!decoded.ok) return formatProblems(decoded.problems).join("; ");
|
|
149
|
+
return host.settings.set(entry, decoded.value);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const ctx = host.session();
|
|
153
|
+
const row = findRow(host, feature.id, request.id, ctx);
|
|
154
|
+
if (row?.kind !== "value" || ctx === undefined) return "this row cannot change";
|
|
155
|
+
if (request.op === "unset") return "this row has no default to go back to";
|
|
156
|
+
return row.set(request.value ?? null, ctx);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The rows a feature has at this moment: the ones it declared, then the ones it builds. */
|
|
160
|
+
function entriesOf(host: Host, feature: string, ctx: ExtensionContext): ScreenEntry[] {
|
|
161
|
+
return [
|
|
162
|
+
...(host.screen.get(feature) ?? []),
|
|
163
|
+
...(host.screenGroups.get(feature) ?? []).flatMap((group) => group(ctx)),
|
|
164
|
+
];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** An action can sit on a built row too, so the lookup walks the same two places. */
|
|
168
|
+
function findRow(
|
|
169
|
+
host: Host,
|
|
170
|
+
feature: string,
|
|
171
|
+
id: string,
|
|
172
|
+
ctx: ExtensionContext | undefined,
|
|
173
|
+
): ScreenEntry | undefined {
|
|
174
|
+
if (ctx === undefined) return host.screen.get(feature)?.find((candidate) => candidate.id === id);
|
|
175
|
+
return entriesOf(host, feature, ctx).find((candidate) => candidate.id === id);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function runAction(
|
|
179
|
+
row: Extract<ScreenEntry, { kind: "action" }>,
|
|
180
|
+
ctx: ExtensionContext,
|
|
181
|
+
): Promise<Omit<RunDone, "request">> {
|
|
182
|
+
try {
|
|
183
|
+
const text = await row.run(ctx);
|
|
184
|
+
return text === undefined ? {} : { text };
|
|
185
|
+
} catch (error) {
|
|
186
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asApply(data: unknown): ApplyRequest | undefined {
|
|
191
|
+
if (!isObject(data) || data.answer !== undefined) return undefined;
|
|
192
|
+
const { feature, id, op } = data;
|
|
193
|
+
if (typeof feature !== "string" || typeof id !== "string") return undefined;
|
|
194
|
+
if (op !== "set" && op !== "unset") return undefined;
|
|
195
|
+
return data as unknown as ApplyRequest;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function asRun(data: unknown): RunRequest | undefined {
|
|
199
|
+
if (!isObject(data) || data.answer !== undefined) return undefined;
|
|
200
|
+
const { feature, id, request } = data;
|
|
201
|
+
if (typeof feature !== "string" || typeof id !== "string" || typeof request !== "string")
|
|
202
|
+
return undefined;
|
|
203
|
+
return data as unknown as RunRequest;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function toJson(value: unknown): Json {
|
|
207
|
+
return value === undefined ? null : (JSON.parse(JSON.stringify(value)) as Json);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function capitalize(text: string): string {
|
|
211
|
+
return text.charAt(0).toUpperCase() + text.slice(1);
|
|
212
|
+
}
|
package/src/app/scope.ts
CHANGED
|
@@ -2,7 +2,13 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import type { SettingsStore } from "../settings/store.ts";
|
|
4
4
|
import { attributed } from "./attribution.ts";
|
|
5
|
-
import type {
|
|
5
|
+
import type {
|
|
6
|
+
FeatureScope,
|
|
7
|
+
ScreenEntry,
|
|
8
|
+
ScreenGroup,
|
|
9
|
+
SessionHook,
|
|
10
|
+
ShutdownHook,
|
|
11
|
+
} from "./feature.ts";
|
|
6
12
|
|
|
7
13
|
export interface Hook<T> {
|
|
8
14
|
/** The feature that registered it, named when it fails. */
|
|
@@ -20,11 +26,24 @@ export interface AppState {
|
|
|
20
26
|
commands: Map<string, string>;
|
|
21
27
|
starts: Hook<SessionHook>[];
|
|
22
28
|
shutdowns: Hook<ShutdownHook>[];
|
|
29
|
+
screen: Map<string, ScreenEntry[]>;
|
|
30
|
+
screenGroups: Map<string, ScreenGroup[]>;
|
|
23
31
|
report(source: string, message: string): void;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
export function createScope(state: AppState, featureId: string): FeatureScope {
|
|
27
35
|
const label = `${state.name}: ${featureId}`;
|
|
36
|
+
const rows: ScreenEntry[] = state.screen.get(featureId) ?? [];
|
|
37
|
+
state.screen.set(featureId, rows);
|
|
38
|
+
const groups: ScreenGroup[] = state.screenGroups.get(featureId) ?? [];
|
|
39
|
+
state.screenGroups.set(featureId, groups);
|
|
40
|
+
const add = (row: ScreenEntry): void => {
|
|
41
|
+
if (rows.some((entry) => entry.id === row.id)) {
|
|
42
|
+
state.report(featureId, `the screen row "${row.id}" was added twice; keeping the first`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
rows.push(row);
|
|
46
|
+
};
|
|
28
47
|
|
|
29
48
|
return {
|
|
30
49
|
...state.pi,
|
|
@@ -47,6 +66,12 @@ export function createScope(state: AppState, featureId: string): FeatureScope {
|
|
|
47
66
|
onShutdown: (run) => state.shutdowns.push({ source: featureId, run }),
|
|
48
67
|
warn: (message) => state.report(featureId, message),
|
|
49
68
|
has: (id) => state.mounted.has(id),
|
|
69
|
+
screen: {
|
|
70
|
+
value: (row) => add({ kind: "value", ...row }),
|
|
71
|
+
action: (row) => add({ kind: "action", ...row }),
|
|
72
|
+
info: (row) => add({ kind: "info", ...row }),
|
|
73
|
+
rows: (provider) => void groups.push(provider),
|
|
74
|
+
},
|
|
50
75
|
} as FeatureScope;
|
|
51
76
|
}
|
|
52
77
|
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
ANSWER,
|
|
5
|
+
ASK,
|
|
6
|
+
AVAILABLE,
|
|
7
|
+
type AskQuestion,
|
|
8
|
+
type AskRequest,
|
|
9
|
+
type AskResult,
|
|
10
|
+
isAskResult,
|
|
11
|
+
} from "../contracts/ask.ts";
|
|
12
|
+
import { isObject } from "../decode.ts";
|
|
13
|
+
|
|
14
|
+
type Events = ExtensionAPI["events"];
|
|
15
|
+
|
|
16
|
+
/** True when a feature in this process can draw a question right now. */
|
|
17
|
+
export function canAsk(events: Events): boolean {
|
|
18
|
+
const probe = { available: false };
|
|
19
|
+
events.emit(AVAILABLE, probe);
|
|
20
|
+
return probe.available;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Asks, and resolves with the answer. Check `canAsk` first: with no provider listening, the promise
|
|
25
|
+
* never settles. A provider that is listening always answers, even when it fails.
|
|
26
|
+
*/
|
|
27
|
+
export function askQuestions(
|
|
28
|
+
events: Events,
|
|
29
|
+
questions: readonly AskQuestion[],
|
|
30
|
+
): Promise<AskResult> {
|
|
31
|
+
const id = crypto.randomUUID();
|
|
32
|
+
return new Promise((resolve) => {
|
|
33
|
+
const stop = events.on(ANSWER, (data) => {
|
|
34
|
+
if (!isObject(data) || data.id !== id) return;
|
|
35
|
+
stop();
|
|
36
|
+
resolve(asResult(data.result));
|
|
37
|
+
});
|
|
38
|
+
const request: AskRequest = { id, questions: [...questions] };
|
|
39
|
+
events.emit(ASK, request);
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function asResult(value: unknown): AskResult {
|
|
44
|
+
if (!isAskResult(value))
|
|
45
|
+
return { answers: [], cancelled: true, error: "the answer did not decode" };
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// One dialog for every question. The permission dialog used to ask in its own way, with its own
|
|
2
|
+
// keys; now whoever needs an answer asks here and the questions feature draws it.
|
|
3
|
+
//
|
|
4
|
+
// The bus is synchronous, so a request carries an id and the answer arrives on a second channel,
|
|
5
|
+
// like the screen's `run` does.
|
|
6
|
+
import { isObject } from "../decode.ts";
|
|
7
|
+
|
|
8
|
+
/** A provider answers this to say it can draw a question right now. */
|
|
9
|
+
export const AVAILABLE = "harness:ask:available";
|
|
10
|
+
export const ASK = "harness:ask";
|
|
11
|
+
export const ANSWER = "harness:ask:answer";
|
|
12
|
+
|
|
13
|
+
export interface AskOption {
|
|
14
|
+
label: string;
|
|
15
|
+
description?: string;
|
|
16
|
+
/** Shown beside the options while this one is focused. */
|
|
17
|
+
preview?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface AskQuestion {
|
|
21
|
+
header: string;
|
|
22
|
+
question: string;
|
|
23
|
+
options: AskOption[];
|
|
24
|
+
multiSelect?: boolean;
|
|
25
|
+
/** A last row for an answer in the user's own words. On unless it is `false`. */
|
|
26
|
+
typed?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface AskRequest {
|
|
30
|
+
id: string;
|
|
31
|
+
questions: AskQuestion[];
|
|
32
|
+
answer?: AskResult;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface AskNote {
|
|
36
|
+
option: string;
|
|
37
|
+
note: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface AskAnswer {
|
|
41
|
+
question: string;
|
|
42
|
+
header: string;
|
|
43
|
+
picked: string[];
|
|
44
|
+
typed?: string;
|
|
45
|
+
notes: AskNote[];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface AskResult {
|
|
49
|
+
answers: AskAnswer[];
|
|
50
|
+
cancelled: boolean;
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface AnswerEvent {
|
|
55
|
+
id: string;
|
|
56
|
+
result: AskResult;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isAskResult(value: unknown): value is AskResult {
|
|
60
|
+
if (!isObject(value)) return false;
|
|
61
|
+
return Array.isArray(value.answers) && typeof value.cancelled === "boolean";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Why a request cannot be used, for a provider reading one from another version. */
|
|
65
|
+
export function decodeAskRequest(value: unknown): string | undefined {
|
|
66
|
+
if (!isObject(value)) return "not an object";
|
|
67
|
+
if (typeof value.id !== "string") return "no id";
|
|
68
|
+
if (!Array.isArray(value.questions)) return "no questions";
|
|
69
|
+
for (const question of value.questions) {
|
|
70
|
+
if (!isObject(question)) return "a question is not an object";
|
|
71
|
+
if (typeof question.header !== "string" || typeof question.question !== "string")
|
|
72
|
+
return "a question has no header or text";
|
|
73
|
+
if (!Array.isArray(question.options) || question.options.length === 0)
|
|
74
|
+
return `"${question.header}" has no options`;
|
|
75
|
+
if (question.typed !== undefined && typeof question.typed !== "boolean")
|
|
76
|
+
return `"${question.header}" has a typed flag that is not a boolean`;
|
|
77
|
+
for (const option of question.options) {
|
|
78
|
+
if (!isObject(option) || typeof option.label !== "string")
|
|
79
|
+
return `"${question.header}" has an option with no label`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|