@cortexkit/common-auth 0.2.5 → 0.2.6
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/dist/commands/builtins.d.ts +71 -0
- package/dist/commands/builtins.js +508 -0
- package/dist/commands/index.d.ts +10 -1
- package/dist/commands/index.js +5 -2
- package/dist/commands/menu.d.ts +39 -0
- package/dist/commands/menu.js +249 -0
- package/dist/commands/model.d.ts +188 -0
- package/dist/commands/model.js +16 -0
- package/dist/commands/pi.d.ts +22 -0
- package/dist/commands/pi.js +178 -0
- package/dist/commands/seam.d.ts +36 -0
- package/dist/commands/seam.js +185 -0
- package/dist/store/errors.d.ts +1 -1
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +1 -0
- package/dist/store/pool.d.ts +12 -0
- package/dist/store/pool.js +3 -0
- package/dist/store/settings.d.ts +63 -0
- package/dist/store/settings.js +120 -0
- package/package.json +1 -1
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// One slash command per plugin: the menu a plugin creates once and serves to
|
|
2
|
+
// every invocation. It keeps no per-invocation state; each `open` and `apply`
|
|
3
|
+
// builds its sections afresh from the store and gets its own copy of the
|
|
4
|
+
// caller's context.
|
|
5
|
+
import { createLogger } from '../logger/index.js';
|
|
6
|
+
import { builtinSections, } from './builtins.js';
|
|
7
|
+
import { applyResult, confirmationOf, dialogPayload, } from './seam.js';
|
|
8
|
+
const BUILTIN_IDS = new Set([
|
|
9
|
+
'accounts',
|
|
10
|
+
'quota',
|
|
11
|
+
'routing',
|
|
12
|
+
'limits',
|
|
13
|
+
'cache',
|
|
14
|
+
'diagnostics',
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* A private copy of the caller's context, taken before the first await. Work
|
|
18
|
+
* an invocation leaves running reports through this copy, so a host that
|
|
19
|
+
* reuses and rebinds one context object for the next session cannot pull an
|
|
20
|
+
* earlier invocation's feedback over to it.
|
|
21
|
+
*/
|
|
22
|
+
function ownInvocation(invocation) {
|
|
23
|
+
const { sessionId } = invocation;
|
|
24
|
+
const notify = invocation.notify.bind(invocation);
|
|
25
|
+
return Object.freeze({
|
|
26
|
+
...(sessionId !== undefined ? { sessionId } : {}),
|
|
27
|
+
notify,
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function coerceOne(knob, raw) {
|
|
31
|
+
switch (knob.kind) {
|
|
32
|
+
case 'choice': {
|
|
33
|
+
const value = raw ?? knob.value;
|
|
34
|
+
if (typeof value === 'string' &&
|
|
35
|
+
knob.choices.some((choice) => choice.value === value))
|
|
36
|
+
return { value };
|
|
37
|
+
return { problem: `Choose one of the options for ${knob.label}.` };
|
|
38
|
+
}
|
|
39
|
+
case 'toggle': {
|
|
40
|
+
const value = raw ?? knob.value;
|
|
41
|
+
return typeof value === 'boolean'
|
|
42
|
+
? { value }
|
|
43
|
+
: { problem: `${knob.label} must be on or off.` };
|
|
44
|
+
}
|
|
45
|
+
case 'number': {
|
|
46
|
+
const value = raw === undefined ? (knob.value ?? null) : raw;
|
|
47
|
+
if (value === null || value === '') {
|
|
48
|
+
return knob.required
|
|
49
|
+
? { problem: `${knob.label} needs a number.` }
|
|
50
|
+
: { value: null };
|
|
51
|
+
}
|
|
52
|
+
const number = typeof value === 'number' ? value : Number(value);
|
|
53
|
+
if (typeof value === 'boolean' || !Number.isFinite(number))
|
|
54
|
+
return { problem: `${knob.label} needs a number.` };
|
|
55
|
+
if (knob.min !== undefined && number < knob.min)
|
|
56
|
+
return { problem: `${knob.label} must be at least ${knob.min}.` };
|
|
57
|
+
if (knob.max !== undefined && number > knob.max)
|
|
58
|
+
return { problem: `${knob.label} must be at most ${knob.max}.` };
|
|
59
|
+
return { value: number };
|
|
60
|
+
}
|
|
61
|
+
case 'text': {
|
|
62
|
+
const value = raw === undefined ? (knob.value ?? null) : raw;
|
|
63
|
+
if (value === null || value === '')
|
|
64
|
+
return knob.required
|
|
65
|
+
? { problem: `${knob.label} cannot be empty.` }
|
|
66
|
+
: { value: null };
|
|
67
|
+
return typeof value === 'string'
|
|
68
|
+
? { value }
|
|
69
|
+
: { problem: `${knob.label} must be text.` };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** The action's inputs, checked against its knobs; unknown names are ignored. */
|
|
74
|
+
function coerceValues(knobs, raw) {
|
|
75
|
+
const values = {};
|
|
76
|
+
for (const knob of knobs) {
|
|
77
|
+
const given = raw && Object.hasOwn(raw, knob.id) ? raw[knob.id] : undefined;
|
|
78
|
+
const result = coerceOne(knob, given);
|
|
79
|
+
if ('problem' in result)
|
|
80
|
+
return result;
|
|
81
|
+
values[knob.id] = result.value;
|
|
82
|
+
}
|
|
83
|
+
return { values };
|
|
84
|
+
}
|
|
85
|
+
function findAction(sections, request) {
|
|
86
|
+
const section = sections.find((entry) => entry.id === request.sectionId);
|
|
87
|
+
if (!section)
|
|
88
|
+
return undefined;
|
|
89
|
+
const actions = request.itemId === undefined
|
|
90
|
+
? section.content.actions
|
|
91
|
+
: section.content.items?.find((item) => item.id === request.itemId)
|
|
92
|
+
?.actions;
|
|
93
|
+
return actions?.find((action) => action.id === request.actionId);
|
|
94
|
+
}
|
|
95
|
+
export function createCommandMenu(options) {
|
|
96
|
+
const logger = options.logger ?? createLogger('commands');
|
|
97
|
+
const now = options.now ?? Date.now;
|
|
98
|
+
const extras = options.extras ?? [];
|
|
99
|
+
const seen = new Set();
|
|
100
|
+
for (const extra of extras) {
|
|
101
|
+
if (BUILTIN_IDS.has(extra.id) || seen.has(extra.id))
|
|
102
|
+
throw new Error(`extra section id ${extra.id} is taken`);
|
|
103
|
+
seen.add(extra.id);
|
|
104
|
+
}
|
|
105
|
+
async function plugin(id, slot, section, invocation) {
|
|
106
|
+
try {
|
|
107
|
+
return {
|
|
108
|
+
id,
|
|
109
|
+
slot,
|
|
110
|
+
title: section.title,
|
|
111
|
+
content: await section.build(invocation),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
// One broken plugin section must not take the whole menu down.
|
|
116
|
+
logger.warn('command menu section failed to build', {
|
|
117
|
+
command: options.command,
|
|
118
|
+
section: id,
|
|
119
|
+
error: error instanceof Error ? error.message : String(error),
|
|
120
|
+
});
|
|
121
|
+
return {
|
|
122
|
+
id,
|
|
123
|
+
slot,
|
|
124
|
+
title: section.title,
|
|
125
|
+
content: { lines: ['This section could not be loaded.'] },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Every section, in the fixed slot order. */
|
|
130
|
+
async function sections(invocation) {
|
|
131
|
+
const out = await builtinSections({
|
|
132
|
+
store: options.store,
|
|
133
|
+
now,
|
|
134
|
+
...(options.extraLocks ? { extraLocks: options.extraLocks } : {}),
|
|
135
|
+
...(options.accounts ? { accounts: options.accounts } : {}),
|
|
136
|
+
...(options.quota ? { quota: options.quota } : {}),
|
|
137
|
+
...(options.routing ? { routing: options.routing } : {}),
|
|
138
|
+
...(options.limits ? { limits: options.limits } : {}),
|
|
139
|
+
});
|
|
140
|
+
if (options.cache)
|
|
141
|
+
out.push(await plugin('cache', 'cache', options.cache, invocation));
|
|
142
|
+
if (options.diagnostics)
|
|
143
|
+
out.push(await plugin('diagnostics', 'diagnostics', options.diagnostics, invocation));
|
|
144
|
+
for (const extra of extras)
|
|
145
|
+
out.push(await plugin(extra.id, 'extra', extra, invocation));
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
command: options.command,
|
|
150
|
+
async open(invocation) {
|
|
151
|
+
const own = ownInvocation(invocation);
|
|
152
|
+
return dialogPayload(options.command, options.title, await sections(own), logger);
|
|
153
|
+
},
|
|
154
|
+
async apply(request, invocation) {
|
|
155
|
+
const own = ownInvocation(invocation);
|
|
156
|
+
const finish = async (outcome) => applyResult(options.command, options.title, await sections(own), outcome, logger);
|
|
157
|
+
const action = request.command === options.command
|
|
158
|
+
? findAction(await sections(own), request)
|
|
159
|
+
: undefined;
|
|
160
|
+
if (!action)
|
|
161
|
+
return finish({
|
|
162
|
+
ok: false,
|
|
163
|
+
text: 'That action is no longer available.',
|
|
164
|
+
});
|
|
165
|
+
const confirmation = confirmationOf(action);
|
|
166
|
+
if (confirmation && request.confirmed !== true)
|
|
167
|
+
return finish({
|
|
168
|
+
ok: false,
|
|
169
|
+
text: confirmation.message,
|
|
170
|
+
needsConfirmation: true,
|
|
171
|
+
});
|
|
172
|
+
const coerced = coerceValues(action.knobs ?? [], request.values);
|
|
173
|
+
if ('problem' in coerced)
|
|
174
|
+
return finish({ ok: false, text: coerced.problem });
|
|
175
|
+
let outcome;
|
|
176
|
+
try {
|
|
177
|
+
const result = await action.run({
|
|
178
|
+
values: coerced.values,
|
|
179
|
+
...(request.itemId !== undefined ? { itemId: request.itemId } : {}),
|
|
180
|
+
invocation: own,
|
|
181
|
+
});
|
|
182
|
+
outcome =
|
|
183
|
+
typeof result === 'string' ? { ok: true, text: result } : result;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
187
|
+
logger.warn('command menu action failed', {
|
|
188
|
+
command: options.command,
|
|
189
|
+
section: request.sectionId,
|
|
190
|
+
action: request.actionId,
|
|
191
|
+
error: message,
|
|
192
|
+
});
|
|
193
|
+
outcome = { ok: false, text: message };
|
|
194
|
+
}
|
|
195
|
+
return finish(outcome);
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function isKnobValue(value) {
|
|
200
|
+
return (value === null ||
|
|
201
|
+
typeof value === 'string' ||
|
|
202
|
+
typeof value === 'boolean' ||
|
|
203
|
+
(typeof value === 'number' && Number.isFinite(value)));
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Checks an apply request that arrived over the loopback RPC; undefined when
|
|
207
|
+
* it is not one. Only the request's own fields are kept.
|
|
208
|
+
*/
|
|
209
|
+
export function parseApplyRequest(value) {
|
|
210
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
211
|
+
return undefined;
|
|
212
|
+
const raw = value;
|
|
213
|
+
if (typeof raw.command !== 'string' ||
|
|
214
|
+
typeof raw.sectionId !== 'string' ||
|
|
215
|
+
typeof raw.actionId !== 'string')
|
|
216
|
+
return undefined;
|
|
217
|
+
if (raw.itemId !== undefined && typeof raw.itemId !== 'string')
|
|
218
|
+
return undefined;
|
|
219
|
+
if (raw.sessionId !== undefined && typeof raw.sessionId !== 'string')
|
|
220
|
+
return undefined;
|
|
221
|
+
if (raw.confirmed !== undefined && typeof raw.confirmed !== 'boolean')
|
|
222
|
+
return undefined;
|
|
223
|
+
const values = {};
|
|
224
|
+
if (raw.values !== undefined) {
|
|
225
|
+
if (raw.values === null ||
|
|
226
|
+
typeof raw.values !== 'object' ||
|
|
227
|
+
Array.isArray(raw.values))
|
|
228
|
+
return undefined;
|
|
229
|
+
for (const [name, entry] of Object.entries(raw.values)) {
|
|
230
|
+
if (!isKnobValue(entry))
|
|
231
|
+
return undefined;
|
|
232
|
+
Object.defineProperty(values, name, {
|
|
233
|
+
value: entry,
|
|
234
|
+
enumerable: true,
|
|
235
|
+
writable: true,
|
|
236
|
+
configurable: true,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
command: raw.command,
|
|
242
|
+
sectionId: raw.sectionId,
|
|
243
|
+
actionId: raw.actionId,
|
|
244
|
+
...(typeof raw.itemId === 'string' ? { itemId: raw.itemId } : {}),
|
|
245
|
+
...(raw.values !== undefined ? { values } : {}),
|
|
246
|
+
...(raw.confirmed === true ? { confirmed: true } : {}),
|
|
247
|
+
...(typeof raw.sessionId === 'string' ? { sessionId: raw.sessionId } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/** The section slots, in the one order every renderer shows them. */
|
|
2
|
+
export declare const SECTION_SLOTS: readonly ['accounts', 'quota', 'routing', 'limits', 'cache', 'diagnostics', 'extra'];
|
|
3
|
+
export type SectionSlot = (typeof SECTION_SLOTS)[number];
|
|
4
|
+
/** One choice of a `choice` input. */
|
|
5
|
+
export interface MenuChoice {
|
|
6
|
+
value: string;
|
|
7
|
+
label: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A typed input an action collects before it runs. `value` is the current
|
|
11
|
+
* setting, shown as the default. A `number` or `text` input that is not
|
|
12
|
+
* `required` may be left empty, which the action receives as `null`.
|
|
13
|
+
*/
|
|
14
|
+
export type MenuKnob = {
|
|
15
|
+
kind: 'choice';
|
|
16
|
+
id: string;
|
|
17
|
+
label: string;
|
|
18
|
+
choices: MenuChoice[];
|
|
19
|
+
value?: string;
|
|
20
|
+
} | {
|
|
21
|
+
kind: 'toggle';
|
|
22
|
+
id: string;
|
|
23
|
+
label: string;
|
|
24
|
+
value: boolean;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'number';
|
|
27
|
+
id: string;
|
|
28
|
+
label: string;
|
|
29
|
+
value?: number;
|
|
30
|
+
min?: number;
|
|
31
|
+
max?: number;
|
|
32
|
+
required?: boolean;
|
|
33
|
+
} | {
|
|
34
|
+
kind: 'text';
|
|
35
|
+
id: string;
|
|
36
|
+
label: string;
|
|
37
|
+
value?: string;
|
|
38
|
+
placeholder?: string;
|
|
39
|
+
/** Hide what is typed (a pasted secret, for example). */
|
|
40
|
+
masked?: boolean;
|
|
41
|
+
required?: boolean;
|
|
42
|
+
};
|
|
43
|
+
export type KnobValue = string | number | boolean | null;
|
|
44
|
+
export type KnobValues = Readonly<Record<string, KnobValue>>;
|
|
45
|
+
export interface MenuConfirmation {
|
|
46
|
+
message: string;
|
|
47
|
+
/** Irreversible actions always carry a confirmation. */
|
|
48
|
+
irreversible: boolean;
|
|
49
|
+
}
|
|
50
|
+
export interface MenuAction {
|
|
51
|
+
id: string;
|
|
52
|
+
label: string;
|
|
53
|
+
description?: string;
|
|
54
|
+
knobs: MenuKnob[];
|
|
55
|
+
/** Present when the action must be confirmed before it is applied. */
|
|
56
|
+
confirm?: MenuConfirmation;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The account fields a renderer may show, and nothing else: an account is
|
|
60
|
+
* always projected field by field, never spread, so a credential the store
|
|
61
|
+
* row carries cannot reach a payload.
|
|
62
|
+
*/
|
|
63
|
+
export interface MenuAccount {
|
|
64
|
+
id: string;
|
|
65
|
+
label?: string;
|
|
66
|
+
enabled: boolean;
|
|
67
|
+
type: 'oauth' | 'api';
|
|
68
|
+
/** What the plugin chose to show for the account's identity. */
|
|
69
|
+
identity?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface MenuItem {
|
|
72
|
+
id: string;
|
|
73
|
+
label: string;
|
|
74
|
+
detail?: string;
|
|
75
|
+
account?: MenuAccount;
|
|
76
|
+
/**
|
|
77
|
+
* Extra name/value pairs a drawer may list under the item. Plugin data
|
|
78
|
+
* passes through here, so it is scrubbed of credential-shaped names.
|
|
79
|
+
*/
|
|
80
|
+
facts?: Record<string, unknown>;
|
|
81
|
+
actions: MenuAction[];
|
|
82
|
+
}
|
|
83
|
+
export interface MenuSection {
|
|
84
|
+
id: string;
|
|
85
|
+
slot: SectionSlot;
|
|
86
|
+
title: string;
|
|
87
|
+
/** Read-only lines shown above the items. */
|
|
88
|
+
lines: string[];
|
|
89
|
+
items: MenuItem[];
|
|
90
|
+
actions: MenuAction[];
|
|
91
|
+
facts?: Record<string, unknown>;
|
|
92
|
+
}
|
|
93
|
+
export interface CommandMenuModel {
|
|
94
|
+
/** The slash command's name, without the slash. */
|
|
95
|
+
command: string;
|
|
96
|
+
title: string;
|
|
97
|
+
sections: MenuSection[];
|
|
98
|
+
}
|
|
99
|
+
/** What a host's TUI receives when the slash command opens the dialog. */
|
|
100
|
+
export interface CommandDialogPayload {
|
|
101
|
+
command: string;
|
|
102
|
+
menu: CommandMenuModel;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One applied action, as a renderer sends it back. `itemId` names the item
|
|
106
|
+
* the action sits on; absent for a section-level action.
|
|
107
|
+
*/
|
|
108
|
+
export interface CommandApplyRequest {
|
|
109
|
+
command: string;
|
|
110
|
+
sectionId: string;
|
|
111
|
+
itemId?: string;
|
|
112
|
+
actionId: string;
|
|
113
|
+
values?: KnobValues;
|
|
114
|
+
/** True once the user confirmed an action that carries a confirmation. */
|
|
115
|
+
confirmed?: boolean;
|
|
116
|
+
sessionId?: string;
|
|
117
|
+
}
|
|
118
|
+
/** The outcome of an apply: a message for the user and the refreshed menu. */
|
|
119
|
+
export interface CommandApplyResult {
|
|
120
|
+
command: string;
|
|
121
|
+
ok: boolean;
|
|
122
|
+
text: string;
|
|
123
|
+
/** True when the action was refused only for want of a confirmation. */
|
|
124
|
+
needsConfirmation?: boolean;
|
|
125
|
+
menu: CommandMenuModel;
|
|
126
|
+
}
|
|
127
|
+
export type NotifyKind = 'info' | 'warning' | 'error';
|
|
128
|
+
/**
|
|
129
|
+
* The per-invocation context of one slash-command call. The library reads
|
|
130
|
+
* both fields once, when the call starts, and keeps that copy for any work
|
|
131
|
+
* the call leaves running (a login completing later, for example), so a host
|
|
132
|
+
* that reuses one context object across sessions cannot reroute feedback.
|
|
133
|
+
*/
|
|
134
|
+
export interface CommandInvocation {
|
|
135
|
+
sessionId?: string;
|
|
136
|
+
notify(message: string, kind?: NotifyKind): void;
|
|
137
|
+
}
|
|
138
|
+
export interface ActionOutcome {
|
|
139
|
+
ok: boolean;
|
|
140
|
+
text: string;
|
|
141
|
+
}
|
|
142
|
+
export interface ActionInput {
|
|
143
|
+
values: KnobValues;
|
|
144
|
+
/** The item the action sits on; absent for a section-level action. */
|
|
145
|
+
itemId?: string;
|
|
146
|
+
invocation: CommandInvocation;
|
|
147
|
+
}
|
|
148
|
+
type ActionBase = {
|
|
149
|
+
id: string;
|
|
150
|
+
label: string;
|
|
151
|
+
description?: string;
|
|
152
|
+
knobs?: MenuKnob[];
|
|
153
|
+
run(input: ActionInput): Promise<string | ActionOutcome>;
|
|
154
|
+
};
|
|
155
|
+
/**
|
|
156
|
+
* An action and its body. An irreversible action must name its
|
|
157
|
+
* confirmation; a reversible one may.
|
|
158
|
+
*/
|
|
159
|
+
export type ActionDefinition = ActionBase & ({
|
|
160
|
+
irreversible: true;
|
|
161
|
+
confirm: string;
|
|
162
|
+
} | {
|
|
163
|
+
irreversible?: false;
|
|
164
|
+
confirm?: string;
|
|
165
|
+
});
|
|
166
|
+
export interface ItemDefinition {
|
|
167
|
+
id: string;
|
|
168
|
+
label: string;
|
|
169
|
+
detail?: string;
|
|
170
|
+
facts?: Record<string, unknown>;
|
|
171
|
+
actions?: ActionDefinition[];
|
|
172
|
+
}
|
|
173
|
+
export interface SectionContent {
|
|
174
|
+
lines?: string[];
|
|
175
|
+
items?: ItemDefinition[];
|
|
176
|
+
actions?: ActionDefinition[];
|
|
177
|
+
facts?: Record<string, unknown>;
|
|
178
|
+
}
|
|
179
|
+
/** A section a plugin supplies for one of its slots. */
|
|
180
|
+
export interface PluginSection {
|
|
181
|
+
title: string;
|
|
182
|
+
build(invocation: CommandInvocation): SectionContent | Promise<SectionContent>;
|
|
183
|
+
}
|
|
184
|
+
/** A provider extra: a plugin section shown after the fixed slots. */
|
|
185
|
+
export interface PluginExtraSection extends PluginSection {
|
|
186
|
+
id: string;
|
|
187
|
+
}
|
|
188
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The command menu: sections of items, each carrying actions with typed
|
|
2
|
+
// inputs. Two kinds of type live here. The `Menu*` types and the request and
|
|
3
|
+
// result types are plain data: they cross the loopback RPC to a host's TUI
|
|
4
|
+
// and are what an in-process renderer walks. The `*Definition` types carry
|
|
5
|
+
// the action bodies a plugin or the library supplies; they never leave the
|
|
6
|
+
// process and are turned into the plain types only by the seam in `seam.ts`.
|
|
7
|
+
/** The section slots, in the one order every renderer shows them. */
|
|
8
|
+
export const SECTION_SLOTS = [
|
|
9
|
+
'accounts',
|
|
10
|
+
'quota',
|
|
11
|
+
'routing',
|
|
12
|
+
'limits',
|
|
13
|
+
'cache',
|
|
14
|
+
'diagnostics',
|
|
15
|
+
'extra',
|
|
16
|
+
];
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { CommandMenu } from './menu.js';
|
|
2
|
+
import type { NotifyKind } from './model.js';
|
|
3
|
+
/**
|
|
4
|
+
* The part of Pi's `ExtensionUIContext` the renderer uses. `select` and
|
|
5
|
+
* `input` resolve undefined when the user backs out.
|
|
6
|
+
*/
|
|
7
|
+
export interface PiMenuUi {
|
|
8
|
+
select(title: string, options: string[]): Promise<string | undefined>;
|
|
9
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
10
|
+
input(title: string, placeholder?: string): Promise<string | undefined>;
|
|
11
|
+
notify(message: string, type?: NotifyKind): void;
|
|
12
|
+
}
|
|
13
|
+
export interface PiMenuOptions {
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Runs one invocation of the slash command on Pi: sections, then an item or
|
|
18
|
+
* a section action, then the action's inputs and confirmation; after each
|
|
19
|
+
* apply the user is back in the same section with the refreshed menu. Backing
|
|
20
|
+
* out of the section list ends the invocation.
|
|
21
|
+
*/
|
|
22
|
+
export declare function runPiCommandMenu(menu: CommandMenu, ui: PiMenuUi, options?: PiMenuOptions): Promise<void>;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// The Pi renderer: walks the same menu model the OpenCode dialog draws, with
|
|
2
|
+
// Pi's extension UI (`select`, `confirm`, `input`, `notify`). It sees only the
|
|
3
|
+
// payloads the menu's seam produces, and applies actions through the menu,
|
|
4
|
+
// so the confirmation and input checks are the same as on OpenCode.
|
|
5
|
+
/**
|
|
6
|
+
* Shows `entries` with `select` and returns the chosen value. Labels are made
|
|
7
|
+
* unique, since `select` answers with the label.
|
|
8
|
+
*/
|
|
9
|
+
async function pick(ui, title, entries) {
|
|
10
|
+
const byLabel = new Map();
|
|
11
|
+
const labels = [];
|
|
12
|
+
for (const entry of entries) {
|
|
13
|
+
let label = entry.label;
|
|
14
|
+
for (let n = 2; byLabel.has(label); n++)
|
|
15
|
+
label = `${entry.label} (${n})`;
|
|
16
|
+
byLabel.set(label, entry.value);
|
|
17
|
+
labels.push(label);
|
|
18
|
+
}
|
|
19
|
+
const chosen = await ui.select(title, labels);
|
|
20
|
+
return chosen === undefined ? undefined : byLabel.get(chosen);
|
|
21
|
+
}
|
|
22
|
+
function withLines(title, lines) {
|
|
23
|
+
return lines.length > 0 ? `${title}\n${lines.join('\n')}` : title;
|
|
24
|
+
}
|
|
25
|
+
function itemTitle(item) {
|
|
26
|
+
const lines = item.detail ? [item.detail] : [];
|
|
27
|
+
for (const [name, value] of Object.entries(item.facts ?? {}))
|
|
28
|
+
lines.push(`${name}: ${String(value)}`);
|
|
29
|
+
return withLines(item.label, lines);
|
|
30
|
+
}
|
|
31
|
+
/** One knob's value, or undefined when the user backed out or typed nonsense. */
|
|
32
|
+
async function askKnob(ui, knob) {
|
|
33
|
+
switch (knob.kind) {
|
|
34
|
+
case 'choice': {
|
|
35
|
+
const value = await pick(ui, knob.label, knob.choices.map((choice) => ({
|
|
36
|
+
label: choice.value === knob.value
|
|
37
|
+
? `${choice.label} (current)`
|
|
38
|
+
: choice.label,
|
|
39
|
+
value: choice.value,
|
|
40
|
+
})));
|
|
41
|
+
return value === undefined ? undefined : { value };
|
|
42
|
+
}
|
|
43
|
+
case 'toggle': {
|
|
44
|
+
const value = await pick(ui, knob.label, [
|
|
45
|
+
{ label: knob.value ? 'On (current)' : 'On', value: true },
|
|
46
|
+
{ label: knob.value ? 'Off' : 'Off (current)', value: false },
|
|
47
|
+
]);
|
|
48
|
+
return value === undefined ? undefined : { value };
|
|
49
|
+
}
|
|
50
|
+
case 'number': {
|
|
51
|
+
const range = knob.min !== undefined && knob.max !== undefined
|
|
52
|
+
? ` (${knob.min}-${knob.max}${knob.required ? '' : ', empty clears'})`
|
|
53
|
+
: knob.required
|
|
54
|
+
? ''
|
|
55
|
+
: ' (empty clears)';
|
|
56
|
+
const typed = await ui.input(`${knob.label}${range}`, knob.value !== undefined ? String(knob.value) : undefined);
|
|
57
|
+
if (typed === undefined)
|
|
58
|
+
return undefined;
|
|
59
|
+
const text = typed.trim();
|
|
60
|
+
if (text === '')
|
|
61
|
+
return knob.required && knob.value !== undefined
|
|
62
|
+
? { value: knob.value }
|
|
63
|
+
: { value: null };
|
|
64
|
+
const number = Number(text);
|
|
65
|
+
if (!Number.isFinite(number)) {
|
|
66
|
+
ui.notify(`${knob.label} needs a number.`, 'error');
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
return { value: number };
|
|
70
|
+
}
|
|
71
|
+
case 'text': {
|
|
72
|
+
const typed = await ui.input(knob.label, knob.value ?? knob.placeholder);
|
|
73
|
+
if (typed === undefined)
|
|
74
|
+
return undefined;
|
|
75
|
+
const text = typed.trim();
|
|
76
|
+
if (text === '')
|
|
77
|
+
return knob.required && knob.value !== undefined
|
|
78
|
+
? { value: knob.value }
|
|
79
|
+
: { value: null };
|
|
80
|
+
return { value: text };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function askValues(ui, action) {
|
|
85
|
+
const values = {};
|
|
86
|
+
for (const knob of action.knobs) {
|
|
87
|
+
const answer = await askKnob(ui, knob);
|
|
88
|
+
if (!answer)
|
|
89
|
+
return undefined;
|
|
90
|
+
values[knob.id] = answer.value;
|
|
91
|
+
}
|
|
92
|
+
return values;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Runs one invocation of the slash command on Pi: sections, then an item or
|
|
96
|
+
* a section action, then the action's inputs and confirmation; after each
|
|
97
|
+
* apply the user is back in the same section with the refreshed menu. Backing
|
|
98
|
+
* out of the section list ends the invocation.
|
|
99
|
+
*/
|
|
100
|
+
export async function runPiCommandMenu(menu, ui, options = {}) {
|
|
101
|
+
const invocation = {
|
|
102
|
+
...(options.sessionId !== undefined
|
|
103
|
+
? { sessionId: options.sessionId }
|
|
104
|
+
: {}),
|
|
105
|
+
notify: (message, kind) => ui.notify(message, kind),
|
|
106
|
+
};
|
|
107
|
+
let model = (await menu.open(invocation)).menu;
|
|
108
|
+
for (;;) {
|
|
109
|
+
const chosen = await pick(ui, model.title, model.sections.map((section) => ({
|
|
110
|
+
label: section.lines[0]
|
|
111
|
+
? `${section.title}: ${section.lines[0]}`
|
|
112
|
+
: section.title,
|
|
113
|
+
value: section.id,
|
|
114
|
+
})));
|
|
115
|
+
if (chosen === undefined)
|
|
116
|
+
return;
|
|
117
|
+
for (;;) {
|
|
118
|
+
const section = model.sections.find((entry) => entry.id === chosen);
|
|
119
|
+
if (!section)
|
|
120
|
+
break;
|
|
121
|
+
const entries = [
|
|
122
|
+
...section.items.map((item) => ({
|
|
123
|
+
label: item.detail ? `${item.label}: ${item.detail}` : item.label,
|
|
124
|
+
value: { kind: 'item', item },
|
|
125
|
+
})),
|
|
126
|
+
...section.actions.map((action) => ({
|
|
127
|
+
label: action.label,
|
|
128
|
+
value: { kind: 'action', action },
|
|
129
|
+
})),
|
|
130
|
+
];
|
|
131
|
+
if (entries.length === 0) {
|
|
132
|
+
ui.notify(section.lines.join('\n') || 'Nothing to do here.');
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
const choice = await pick(ui, withLines(section.title, section.lines), entries);
|
|
136
|
+
if (!choice)
|
|
137
|
+
break;
|
|
138
|
+
let action;
|
|
139
|
+
let item;
|
|
140
|
+
if (choice.kind === 'item') {
|
|
141
|
+
item = choice.item;
|
|
142
|
+
if (item.actions.length === 0) {
|
|
143
|
+
ui.notify(itemTitle(item));
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
action = await pick(ui, itemTitle(item), item.actions.map((entry) => ({ label: entry.label, value: entry })));
|
|
147
|
+
if (!action)
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
action = choice.action;
|
|
152
|
+
}
|
|
153
|
+
const values = await askValues(ui, action);
|
|
154
|
+
if (!values)
|
|
155
|
+
continue;
|
|
156
|
+
if (action.confirm) {
|
|
157
|
+
const yes = await ui.confirm(action.label, action.confirm.message);
|
|
158
|
+
if (!yes) {
|
|
159
|
+
ui.notify('Cancelled.');
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const result = await menu.apply({
|
|
164
|
+
command: model.command,
|
|
165
|
+
sectionId: section.id,
|
|
166
|
+
...(item ? { itemId: item.id } : {}),
|
|
167
|
+
actionId: action.id,
|
|
168
|
+
values,
|
|
169
|
+
...(action.confirm ? { confirmed: true } : {}),
|
|
170
|
+
...(options.sessionId !== undefined
|
|
171
|
+
? { sessionId: options.sessionId }
|
|
172
|
+
: {}),
|
|
173
|
+
}, invocation);
|
|
174
|
+
ui.notify(result.text, result.ok ? 'info' : 'error');
|
|
175
|
+
model = result.menu;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { ActionDefinition, CommandApplyResult, CommandDialogPayload, ItemDefinition, MenuAccount, MenuConfirmation, SectionContent, SectionSlot } from './model.js';
|
|
2
|
+
/** Where a dropped field is reported. Names only, never values. */
|
|
3
|
+
export interface SeamLogger {
|
|
4
|
+
warn(message: string, data?: unknown): void;
|
|
5
|
+
}
|
|
6
|
+
/** A built-in item may carry the account it shows; plugin items cannot. */
|
|
7
|
+
export interface ResolvedItem extends ItemDefinition {
|
|
8
|
+
account?: MenuAccount;
|
|
9
|
+
}
|
|
10
|
+
/** A section with its bodies, as the menu holds it between builds. */
|
|
11
|
+
export interface ResolvedSection {
|
|
12
|
+
id: string;
|
|
13
|
+
slot: SectionSlot;
|
|
14
|
+
title: string;
|
|
15
|
+
content: Omit<SectionContent, 'items'> & {
|
|
16
|
+
items?: ResolvedItem[];
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** The confirmation shown when an irreversible action names none. */
|
|
20
|
+
export declare const DEFAULT_IRREVERSIBLE_CONFIRMATION = "This cannot be undone. Continue?";
|
|
21
|
+
/**
|
|
22
|
+
* The confirmation an action must pass before it runs, or undefined. An
|
|
23
|
+
* irreversible action always has one, even when its definition (built
|
|
24
|
+
* outside the type checker) names none.
|
|
25
|
+
*/
|
|
26
|
+
export declare function confirmationOf(action: ActionDefinition): MenuConfirmation | undefined;
|
|
27
|
+
/** The account fields a renderer may show; see `MenuAccount`. */
|
|
28
|
+
export declare function projectAccount(account: MenuAccount): MenuAccount;
|
|
29
|
+
/** The payload a host's TUI receives when the slash command opens. */
|
|
30
|
+
export declare function dialogPayload(command: string, title: string, sections: readonly ResolvedSection[], logger: SeamLogger): CommandDialogPayload;
|
|
31
|
+
/** An apply's result: the message and the refreshed menu. */
|
|
32
|
+
export declare function applyResult(command: string, title: string, sections: readonly ResolvedSection[], outcome: {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
text: string;
|
|
35
|
+
needsConfirmation?: boolean;
|
|
36
|
+
}, logger: SeamLogger): CommandApplyResult;
|