@hediet/linkrpc-cli 0.0.1
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 +370 -0
- package/dist/chunks/cli-D_-vlAa2.js +6073 -0
- package/dist/chunks/cli-D_-vlAa2.js.map +1 -0
- package/dist/chunks/runApprovalUi-BvJZdjKm.js +715 -0
- package/dist/chunks/runApprovalUi-BvJZdjKm.js.map +1 -0
- package/dist/chunks/runComplete-BkQwzPbF.js +3949 -0
- package/dist/chunks/runComplete-BkQwzPbF.js.map +1 -0
- package/dist/chunks/runUi-D-8LMU4F.js +1143 -0
- package/dist/chunks/runUi-D-8LMU4F.js.map +1 -0
- package/dist/chunks/scroll-BTzAYh4N.js +93 -0
- package/dist/chunks/scroll-BTzAYh4N.js.map +1 -0
- package/dist/hub.d.ts +1 -0
- package/dist/hub.js +8 -0
- package/dist/hub.js.map +1 -0
- package/dist/index.d.ts +381 -0
- package/dist/index.js +2 -0
- package/dist/linkrpc.d.ts +1 -0
- package/dist/linkrpc.js +8 -0
- package/dist/linkrpc.js.map +1 -0
- package/dist/rpc.d.ts +1 -0
- package/dist/rpc.js +8 -0
- package/dist/rpc.js.map +1 -0
- package/package.json +45 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { D as formatPrincipalSource, L as validateValueAgainstSchema, S as fetchSchema, T as walkHubDetailed, b as setupSigning, k as connect, x as fetchDefaults } from "./runComplete-BkQwzPbF.js";
|
|
2
|
+
import { a as observableValue, c as Disposable, i as ObservablePromise, o as autorun, r as ObservableLazyPromise, s as derived } from "./cli-D_-vlAa2.js";
|
|
3
|
+
import { n as useObservable, t as useScroll } from "./scroll-BTzAYh4N.js";
|
|
4
|
+
import { ChannelConnector, RpcError, SigningSender } from "@hediet/linkrpc";
|
|
5
|
+
import { isHubEndpoint, openHubChannel } from "@hediet/linkrpc/node";
|
|
6
|
+
import React from "react";
|
|
7
|
+
import { Box, Text, render, useApp, useInput, useStdout } from "ink";
|
|
8
|
+
import TextInput from "ink-text-input";
|
|
9
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
10
|
+
//#region src/ui/UiModel.ts
|
|
11
|
+
/**
|
|
12
|
+
* View model for the TUI. Owns the live channel and an in-memory schema cache
|
|
13
|
+
* keyed by interface id. Loading state for every async lookup is expressed as
|
|
14
|
+
* an `ObservablePromise` from `@vscode/observables` — no ad-hoc loading flags.
|
|
15
|
+
*/
|
|
16
|
+
var UiModel = class extends Disposable {
|
|
17
|
+
_channel;
|
|
18
|
+
/** Initial directory fetch — kicked off in the constructor. */
|
|
19
|
+
servicesPromise;
|
|
20
|
+
/** Non-fatal discovery failures for services that could not be inspected. */
|
|
21
|
+
discoveryWarnings = observableValue("UiModel.discoveryWarnings", []);
|
|
22
|
+
selection = observableValue("UiModel.selection", void 0);
|
|
23
|
+
formValues = observableValue("UiModel.formValues", {});
|
|
24
|
+
/** Index of the currently-focused form field (clamped at render time). */
|
|
25
|
+
formCursor = observableValue("UiModel.formCursor", 0);
|
|
26
|
+
/** True when the focused field is in input mode (text being typed). */
|
|
27
|
+
formEditing = observableValue("UiModel.formEditing", false);
|
|
28
|
+
/** Which Miller column currently owns keyboard focus. */
|
|
29
|
+
focusedColumn = observableValue("UiModel.focusedColumn", 0);
|
|
30
|
+
rawJsonMode = observableValue("UiModel.rawJsonMode", false);
|
|
31
|
+
rawJsonText = observableValue("UiModel.rawJsonText", "{}");
|
|
32
|
+
history = observableValue("UiModel.history", []);
|
|
33
|
+
/**
|
|
34
|
+
* Wraps the in-flight call (if any). `undefined` until the user first
|
|
35
|
+
* submits, then re-assigned on each submit. The view reads
|
|
36
|
+
* `promiseResult` to render loading / result / error.
|
|
37
|
+
*/
|
|
38
|
+
lastCall = observableValue("UiModel.lastCall", void 0);
|
|
39
|
+
/**
|
|
40
|
+
* Server→client stream messages received during the in-flight call.
|
|
41
|
+
* Reset to `[]` on each `submit()`, then appended to as `$stream::send`
|
|
42
|
+
* notifications arrive. The view renders these live below the result.
|
|
43
|
+
*/
|
|
44
|
+
streamChunks = observableValue("UiModel.streamChunks", []);
|
|
45
|
+
/**
|
|
46
|
+
* One-line, human-readable description of the identity signing outbound
|
|
47
|
+
* calls (e.g. `managed (node abcd012345…)`). Rendered in the header.
|
|
48
|
+
* `undefined` until `runUi` resolves the principal.
|
|
49
|
+
*/
|
|
50
|
+
identity = observableValue("UiModel.identity", void 0);
|
|
51
|
+
/**
|
|
52
|
+
* Per-interface schema cache. `ObservableLazyPromise` defers the actual
|
|
53
|
+
* fetch until something asks for it; the autorun below makes "something
|
|
54
|
+
* asks" happen whenever a method on that interface is selected.
|
|
55
|
+
*/
|
|
56
|
+
_schemas = /* @__PURE__ */ new Map();
|
|
57
|
+
constructor(_channel) {
|
|
58
|
+
super();
|
|
59
|
+
this._channel = _channel;
|
|
60
|
+
this.servicesPromise = ObservablePromise.fromFn(async () => {
|
|
61
|
+
const result = await loadUiServices(_channel);
|
|
62
|
+
this.discoveryWarnings.set(result.warnings, void 0);
|
|
63
|
+
return result.services;
|
|
64
|
+
});
|
|
65
|
+
this.servicesPromise.promise.catch(() => {});
|
|
66
|
+
this._register(autorun((reader) => {
|
|
67
|
+
const sel = this.selection.read(reader);
|
|
68
|
+
if (!sel) return;
|
|
69
|
+
this._getOrCreateSchema(sel.serviceId, sel.interfaceId, sel.isDefault === true).getPromise().catch(() => {});
|
|
70
|
+
}));
|
|
71
|
+
this._register(autorun((reader) => {
|
|
72
|
+
if (this.focusedColumn.read(reader) !== 1) return;
|
|
73
|
+
const sel = this.selection.read(reader);
|
|
74
|
+
if (!sel || sel.methodName !== void 0) return;
|
|
75
|
+
const methods = this.currentMethods.read(reader);
|
|
76
|
+
if (methods.length === 0) return;
|
|
77
|
+
this.selection.set({
|
|
78
|
+
...sel,
|
|
79
|
+
methodName: methods[0].name
|
|
80
|
+
}, void 0);
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
currentSchemaState = derived(this, (reader) => {
|
|
84
|
+
const sel = this.selection.read(reader);
|
|
85
|
+
if (!sel) return { kind: "none" };
|
|
86
|
+
const result = this._getOrCreateSchema(sel.serviceId, sel.interfaceId, sel.isDefault === true).cachedPromiseResult.read(reader);
|
|
87
|
+
if (!result) return { kind: "loading" };
|
|
88
|
+
if (result.error) return {
|
|
89
|
+
kind: "error",
|
|
90
|
+
error: result.error
|
|
91
|
+
};
|
|
92
|
+
const schema = result.data;
|
|
93
|
+
const methodSchema = sel.methodName !== void 0 ? schema.methods[sel.methodName] : void 0;
|
|
94
|
+
return {
|
|
95
|
+
kind: "loaded",
|
|
96
|
+
schema,
|
|
97
|
+
method: methodSchema && sel.methodName !== void 0 ? {
|
|
98
|
+
...methodSchema,
|
|
99
|
+
name: sel.methodName
|
|
100
|
+
} : void 0
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
/** Flat list of top-level fields for the currently-selected method. */
|
|
104
|
+
currentFields = derived(this, (reader) => {
|
|
105
|
+
const state = this.currentSchemaState.read(reader);
|
|
106
|
+
if (state.kind !== "loaded" || !state.method) return [];
|
|
107
|
+
const paramsSchema = state.method.params;
|
|
108
|
+
return collectTopLevelFields(paramsSchema, state.schema.components?.schemas ?? {});
|
|
109
|
+
});
|
|
110
|
+
/** All methods on the currently-selected interface (column 2). */
|
|
111
|
+
currentMethods = derived(this, (reader) => {
|
|
112
|
+
const state = this.currentSchemaState.read(reader);
|
|
113
|
+
return state.kind === "loaded" ? Object.entries(state.schema.methods).map(([name, method]) => ({
|
|
114
|
+
name,
|
|
115
|
+
...method
|
|
116
|
+
})) : [];
|
|
117
|
+
});
|
|
118
|
+
/**
|
|
119
|
+
* Whether the currently-selected method declares stream payloads in
|
|
120
|
+
* either direction. Drives the "streaming" affordance in the view.
|
|
121
|
+
*/
|
|
122
|
+
currentMethodStreams = derived(this, (reader) => {
|
|
123
|
+
const state = this.currentSchemaState.read(reader);
|
|
124
|
+
if (state.kind !== "loaded" || !state.method) return {
|
|
125
|
+
server: false,
|
|
126
|
+
client: false
|
|
127
|
+
};
|
|
128
|
+
return {
|
|
129
|
+
server: state.method.serverStream !== void 0,
|
|
130
|
+
client: state.method.clientStream !== void 0
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
/** Per-field validation errors against the live method schema. */
|
|
134
|
+
formErrors = derived(this, (reader) => {
|
|
135
|
+
const state = this.currentSchemaState.read(reader);
|
|
136
|
+
const errors = /* @__PURE__ */ new Map();
|
|
137
|
+
if (state.kind !== "loaded" || !state.method) return errors;
|
|
138
|
+
const paramsSchema = state.method.params;
|
|
139
|
+
const components = state.schema.components?.schemas ?? {};
|
|
140
|
+
const resolvedParamsSchema = resolveSchema(paramsSchema, components);
|
|
141
|
+
if (!isObjectSchema(resolvedParamsSchema)) return errors;
|
|
142
|
+
const values = this.formValues.read(reader);
|
|
143
|
+
const required = new Set(resolvedParamsSchema.required ?? []);
|
|
144
|
+
for (const [name, sub] of Object.entries(resolvedParamsSchema.properties)) {
|
|
145
|
+
const value = values[name];
|
|
146
|
+
if (value === void 0) {
|
|
147
|
+
if (required.has(name)) errors.set(name, "required");
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const reason = validateValueAgainstSchema(value, sub, components);
|
|
151
|
+
if (reason !== void 0) errors.set(name, reason);
|
|
152
|
+
}
|
|
153
|
+
return errors;
|
|
154
|
+
});
|
|
155
|
+
canSubmit = derived(this, (reader) => {
|
|
156
|
+
const sel = this.selection.read(reader);
|
|
157
|
+
if (!sel || sel.methodName === void 0) return false;
|
|
158
|
+
const state = this.currentSchemaState.read(reader);
|
|
159
|
+
if (state.kind !== "loaded" || !state.method) return false;
|
|
160
|
+
return this.formErrors.read(reader).size === 0;
|
|
161
|
+
});
|
|
162
|
+
select(key) {
|
|
163
|
+
this.selection.set(key, void 0);
|
|
164
|
+
this.formValues.set({}, void 0);
|
|
165
|
+
this.rawJsonText.set("{}", void 0);
|
|
166
|
+
this.formCursor.set(0, void 0);
|
|
167
|
+
this.formEditing.set(false, void 0);
|
|
168
|
+
}
|
|
169
|
+
/** Pick a concrete method on the currently-selected service/interface. */
|
|
170
|
+
selectMethod(methodName) {
|
|
171
|
+
const sel = this.selection.get();
|
|
172
|
+
if (!sel) return;
|
|
173
|
+
this.select({
|
|
174
|
+
...sel,
|
|
175
|
+
methodName
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
focusColumn(column) {
|
|
179
|
+
this.focusedColumn.set(column, void 0);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Move the column-0 cursor by `delta`. The cursor is implicit: it's the
|
|
183
|
+
* row whose `(serviceId, interfaceId)` matches the current selection.
|
|
184
|
+
* Stepping off the ends clamps; methodName is cleared so the methods
|
|
185
|
+
* column re-previews the new service.
|
|
186
|
+
*/
|
|
187
|
+
moveServiceCursor(delta) {
|
|
188
|
+
const services = this.servicesPromise.promiseResult.get()?.data ?? [];
|
|
189
|
+
if (services.length === 0) return;
|
|
190
|
+
const sel = this.selection.get();
|
|
191
|
+
const currentIdx = sel ? services.findIndex((s) => s.serviceId === sel.serviceId && s.interfaceId === sel.interfaceId && s.isDefault === true === (sel.isDefault === true)) : -1;
|
|
192
|
+
const next = services[Math.max(0, Math.min(services.length - 1, currentIdx + delta))];
|
|
193
|
+
this.select({
|
|
194
|
+
serviceId: next.serviceId,
|
|
195
|
+
interfaceId: next.interfaceId,
|
|
196
|
+
methodName: void 0,
|
|
197
|
+
...next.isDefault === true ? { isDefault: true } : {}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Move the column-1 cursor by `delta`. Triggers a full `select()` so the
|
|
202
|
+
* form column starts fresh for the newly-previewed method (Miller-style
|
|
203
|
+
* "each cursor move is a new preview").
|
|
204
|
+
*/
|
|
205
|
+
moveMethodCursor(delta) {
|
|
206
|
+
const methods = this.currentMethods.get();
|
|
207
|
+
if (methods.length === 0) return;
|
|
208
|
+
const sel = this.selection.get();
|
|
209
|
+
if (!sel) return;
|
|
210
|
+
const currentIdx = sel.methodName !== void 0 ? methods.findIndex((m) => m.name === sel.methodName) : -1;
|
|
211
|
+
const nextIdx = Math.max(0, Math.min(methods.length - 1, currentIdx + delta));
|
|
212
|
+
this.selectMethod(methods[nextIdx].name);
|
|
213
|
+
}
|
|
214
|
+
setField(name, value) {
|
|
215
|
+
const current = this.formValues.get();
|
|
216
|
+
if (value === void 0) {
|
|
217
|
+
const { [name]: _drop, ...rest } = current;
|
|
218
|
+
this.formValues.set(rest, void 0);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
this.formValues.set({
|
|
222
|
+
...current,
|
|
223
|
+
[name]: value
|
|
224
|
+
}, void 0);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Returns the exact `{ method, params }` `submit` would send right
|
|
228
|
+
* now, or `undefined` when no method is fully selected. Lets a host
|
|
229
|
+
* (e.g. the explorer's access-request prompt) preview the concrete
|
|
230
|
+
* call before issuing a one-shot capability bound to it.
|
|
231
|
+
*/
|
|
232
|
+
peekPendingCall() {
|
|
233
|
+
const sel = this.selection.get();
|
|
234
|
+
if (!sel || sel.methodName === void 0) return void 0;
|
|
235
|
+
const params = this._collectParams();
|
|
236
|
+
return {
|
|
237
|
+
method: sel.isDefault === true ? sel.methodName : sel.serviceId ? `${sel.serviceId}::${sel.interfaceId}::${sel.methodName}` : `${sel.interfaceId}::${sel.methodName}`,
|
|
238
|
+
params
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
submit() {
|
|
242
|
+
const sel = this.selection.get();
|
|
243
|
+
if (!sel || sel.methodName === void 0) return;
|
|
244
|
+
const params = this._collectParams();
|
|
245
|
+
const wireMethod = sel.isDefault === true ? sel.methodName : sel.serviceId ? `${sel.serviceId}::${sel.interfaceId}::${sel.methodName}` : `${sel.interfaceId}::${sel.methodName}`;
|
|
246
|
+
const start = performance.now();
|
|
247
|
+
this.streamChunks.set([], void 0);
|
|
248
|
+
const promise = ObservablePromise.fromFn(async () => {
|
|
249
|
+
return {
|
|
250
|
+
result: await this._channel.sendRequestWithStream(wireMethod, params, { onStreamMessage: (payload) => {
|
|
251
|
+
this.streamChunks.set([...this.streamChunks.get(), payload], void 0);
|
|
252
|
+
} }).result,
|
|
253
|
+
latencyMs: performance.now() - start
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
this.lastCall.set(promise, void 0);
|
|
257
|
+
promise.promise.then((outcome) => {
|
|
258
|
+
this.history.set([...this.history.get(), {
|
|
259
|
+
method: wireMethod,
|
|
260
|
+
params,
|
|
261
|
+
result: outcome.result,
|
|
262
|
+
error: void 0,
|
|
263
|
+
latencyMs: outcome.latencyMs,
|
|
264
|
+
timestamp: Date.now()
|
|
265
|
+
}], void 0);
|
|
266
|
+
}, (error) => {
|
|
267
|
+
const latencyMs = performance.now() - start;
|
|
268
|
+
this.history.set([...this.history.get(), {
|
|
269
|
+
method: wireMethod,
|
|
270
|
+
params,
|
|
271
|
+
result: void 0,
|
|
272
|
+
error,
|
|
273
|
+
latencyMs,
|
|
274
|
+
timestamp: Date.now()
|
|
275
|
+
}], void 0);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
_collectParams() {
|
|
279
|
+
if (this.rawJsonMode.get()) {
|
|
280
|
+
const text = this.rawJsonText.get().trim();
|
|
281
|
+
if (text.length === 0) return void 0;
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(text);
|
|
284
|
+
} catch {
|
|
285
|
+
return text;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return this.formValues.get();
|
|
289
|
+
}
|
|
290
|
+
_getOrCreateSchema(serviceId, interfaceId, isDefault) {
|
|
291
|
+
const key = `${isDefault ? "default" : serviceId}::${interfaceId}`;
|
|
292
|
+
let p = this._schemas.get(key);
|
|
293
|
+
if (!p) {
|
|
294
|
+
const match = (this.servicesPromise.promiseResult.get()?.data ?? []).find((s) => s.serviceId === serviceId && s.interfaceId === interfaceId && s.isDefault === true === isDefault);
|
|
295
|
+
const reporter = isDefault ? "" : match?.discoveredFrom ?? serviceId;
|
|
296
|
+
const target = reporter === "" ? void 0 : reporter;
|
|
297
|
+
p = new ObservableLazyPromise(() => fetchSchema(this._channel, interfaceId, match?.hash, target));
|
|
298
|
+
this._schemas.set(key, p);
|
|
299
|
+
}
|
|
300
|
+
return p;
|
|
301
|
+
}
|
|
302
|
+
};
|
|
303
|
+
async function loadUiServices(channel) {
|
|
304
|
+
const [walkResult, defaultsResult] = await Promise.all([walkHubDetailed(channel), fetchDefaults(channel).then((defaults) => ({
|
|
305
|
+
ok: true,
|
|
306
|
+
defaults
|
|
307
|
+
}), (error) => ({
|
|
308
|
+
ok: false,
|
|
309
|
+
error
|
|
310
|
+
}))]);
|
|
311
|
+
const warnings = walkResult.inaccessible.map(({ serviceId, reason }) => `Service "${serviceId}" does not offer a usable hubrpc.directory::list: ${reason}`);
|
|
312
|
+
if (!defaultsResult.ok) {
|
|
313
|
+
warnings.push(`The root service does not offer hubrpc.defaults::get: ${getErrorMessage(defaultsResult.error)}`);
|
|
314
|
+
return {
|
|
315
|
+
services: walkResult.listings,
|
|
316
|
+
warnings
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
const { defaults } = defaultsResult;
|
|
320
|
+
if (defaults.interfaceId === void 0) return {
|
|
321
|
+
services: walkResult.listings,
|
|
322
|
+
warnings
|
|
323
|
+
};
|
|
324
|
+
return {
|
|
325
|
+
services: [{
|
|
326
|
+
serviceId: "",
|
|
327
|
+
interfaceId: defaults.interfaceId,
|
|
328
|
+
...defaults.hash === void 0 ? {} : { hash: defaults.hash },
|
|
329
|
+
discoveredFrom: "",
|
|
330
|
+
isDefault: true
|
|
331
|
+
}, ...walkResult.listings],
|
|
332
|
+
warnings
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function getErrorMessage(error) {
|
|
336
|
+
return error instanceof Error ? error.message : String(error);
|
|
337
|
+
}
|
|
338
|
+
function collectTopLevelFields(schema, components) {
|
|
339
|
+
const resolved = resolveSchema(schema, components);
|
|
340
|
+
if (!isObjectSchema(resolved)) return [];
|
|
341
|
+
const required = new Set(resolved.required ?? []);
|
|
342
|
+
return Object.entries(resolved.properties).map(([name, sub]) => ({
|
|
343
|
+
name,
|
|
344
|
+
required: required.has(name),
|
|
345
|
+
schema: resolveSchema(sub, components) ?? sub
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
function resolveSchema(schema, components, seen = /* @__PURE__ */ new Set()) {
|
|
349
|
+
if (schema === void 0 || typeof schema !== "object" || !("$ref" in schema)) return schema;
|
|
350
|
+
if (!schema.$ref.startsWith("#/components/schemas/") || seen.has(schema.$ref)) return schema;
|
|
351
|
+
const target = components[schema.$ref.slice(21)];
|
|
352
|
+
if (target === void 0) return schema;
|
|
353
|
+
return resolveSchema(target, components, /* @__PURE__ */ new Set([...seen, schema.$ref]));
|
|
354
|
+
}
|
|
355
|
+
function isObjectSchema(s) {
|
|
356
|
+
return !!s && typeof s === "object" && "type" in s && s.type === "object" && "properties" in s;
|
|
357
|
+
}
|
|
358
|
+
//#endregion
|
|
359
|
+
//#region src/ui/schemaInspect.ts
|
|
360
|
+
function classifyField(schema) {
|
|
361
|
+
if (typeof schema === "boolean") return { kind: "json" };
|
|
362
|
+
if ("enum" in schema) return {
|
|
363
|
+
kind: "enum",
|
|
364
|
+
enumValues: schema.enum
|
|
365
|
+
};
|
|
366
|
+
if ("const" in schema) return {
|
|
367
|
+
kind: "enum",
|
|
368
|
+
enumValues: [schema.const]
|
|
369
|
+
};
|
|
370
|
+
if ("anyOf" in schema) {
|
|
371
|
+
const values = [];
|
|
372
|
+
for (const branch of schema.anyOf) if (typeof branch === "object" && "const" in branch) values.push(branch.const);
|
|
373
|
+
else if (typeof branch === "object" && "enum" in branch) values.push(...branch.enum);
|
|
374
|
+
else return { kind: "json" };
|
|
375
|
+
return {
|
|
376
|
+
kind: "enum",
|
|
377
|
+
enumValues: values
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
if ("type" in schema && typeof schema.type === "string") switch (schema.type) {
|
|
381
|
+
case "string": return { kind: "string" };
|
|
382
|
+
case "number": return { kind: "number" };
|
|
383
|
+
case "integer": return { kind: "integer" };
|
|
384
|
+
case "boolean": return { kind: "boolean" };
|
|
385
|
+
default: return { kind: "json" };
|
|
386
|
+
}
|
|
387
|
+
return { kind: "json" };
|
|
388
|
+
}
|
|
389
|
+
/** A sensible default value for a freshly-focused field, by kind. */
|
|
390
|
+
function defaultValueFor(c) {
|
|
391
|
+
switch (c.kind) {
|
|
392
|
+
case "string": return "";
|
|
393
|
+
case "number":
|
|
394
|
+
case "integer": return 0;
|
|
395
|
+
case "boolean": return false;
|
|
396
|
+
case "enum": return c.enumValues?.[0] ?? null;
|
|
397
|
+
case "json": return null;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* Move to the next/previous value in an enum field. Wraps at the ends. The
|
|
402
|
+
* cycle is what powers `←`/`→` on enum fields without needing edit mode.
|
|
403
|
+
*/
|
|
404
|
+
function cycleEnum(values, current, delta) {
|
|
405
|
+
if (values.length === 0) return current;
|
|
406
|
+
const idx = values.findIndex((v) => deepEqual(v, current));
|
|
407
|
+
return values[(idx < 0 ? 0 : idx + delta + values.length) % values.length];
|
|
408
|
+
}
|
|
409
|
+
function deepEqual(a, b) {
|
|
410
|
+
if (a === b) return true;
|
|
411
|
+
if (typeof a !== typeof b) return false;
|
|
412
|
+
if (a === null || b === null) return false;
|
|
413
|
+
if (typeof a !== "object") return false;
|
|
414
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
415
|
+
}
|
|
416
|
+
/** Parse a user-typed string into a value compatible with the given kind. */
|
|
417
|
+
function parseTypedValue(raw, kind) {
|
|
418
|
+
switch (kind) {
|
|
419
|
+
case "string": return {
|
|
420
|
+
ok: true,
|
|
421
|
+
value: raw
|
|
422
|
+
};
|
|
423
|
+
case "number": {
|
|
424
|
+
if (raw.trim() === "") return {
|
|
425
|
+
ok: true,
|
|
426
|
+
value: void 0
|
|
427
|
+
};
|
|
428
|
+
const n = Number(raw);
|
|
429
|
+
if (!Number.isFinite(n)) return {
|
|
430
|
+
ok: false,
|
|
431
|
+
error: `not a number: ${raw}`
|
|
432
|
+
};
|
|
433
|
+
return {
|
|
434
|
+
ok: true,
|
|
435
|
+
value: n
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
case "integer": {
|
|
439
|
+
if (raw.trim() === "") return {
|
|
440
|
+
ok: true,
|
|
441
|
+
value: void 0
|
|
442
|
+
};
|
|
443
|
+
const n = Number(raw);
|
|
444
|
+
if (!Number.isInteger(n)) return {
|
|
445
|
+
ok: false,
|
|
446
|
+
error: `not an integer: ${raw}`
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
ok: true,
|
|
450
|
+
value: n
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
case "json":
|
|
454
|
+
if (raw.trim() === "") return {
|
|
455
|
+
ok: true,
|
|
456
|
+
value: void 0
|
|
457
|
+
};
|
|
458
|
+
try {
|
|
459
|
+
return {
|
|
460
|
+
ok: true,
|
|
461
|
+
value: JSON.parse(raw)
|
|
462
|
+
};
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
error: e.message
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
case "boolean":
|
|
470
|
+
case "enum": return {
|
|
471
|
+
ok: false,
|
|
472
|
+
error: "not editable via text input"
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
/** A short human description of the schema, shown in dim text after the value. */
|
|
477
|
+
function describeSchema(s) {
|
|
478
|
+
if (typeof s === "boolean") return s ? "any" : "never";
|
|
479
|
+
if ("type" in s && typeof s.type === "string") return s.type;
|
|
480
|
+
if ("enum" in s) return `enum(${s.enum.map((v) => JSON.stringify(v)).join(" | ")})`;
|
|
481
|
+
if ("const" in s) return `const ${JSON.stringify(s.const)}`;
|
|
482
|
+
if ("anyOf" in s) return s.anyOf.map(describeSchema).join(" | ");
|
|
483
|
+
if ("$ref" in s) return s.$ref;
|
|
484
|
+
return "?";
|
|
485
|
+
}
|
|
486
|
+
//#endregion
|
|
487
|
+
//#region src/ui/FieldRow.tsx
|
|
488
|
+
const NAME_W = 12;
|
|
489
|
+
const VALUE_W = 24;
|
|
490
|
+
/**
|
|
491
|
+
* One row of the method form. To keep ink/yoga happy across resizes, the
|
|
492
|
+
* non-editing path renders the whole row as a single `<Text>` with nested
|
|
493
|
+
* colored spans, instead of multiple Boxes that try to share the column's
|
|
494
|
+
* width. Boxes-around-Text in row direction tend to collapse to the
|
|
495
|
+
* narrowest measurement on the first paint — single-Text rows lay out
|
|
496
|
+
* predictably.
|
|
497
|
+
*
|
|
498
|
+
* The editing path swaps the value column for a `TextInput`; the surrounding
|
|
499
|
+
* row stays a Box because TextInput is a component, not a string.
|
|
500
|
+
*/
|
|
501
|
+
const FieldRow = ({ model, field, value, focused, editing, error }) => {
|
|
502
|
+
const cls = classifyField(field.schema);
|
|
503
|
+
const cursor = focused ? "› " : " ";
|
|
504
|
+
const namePadded = pad(field.name, NAME_W);
|
|
505
|
+
const optionalMark = field.required ? "" : "?";
|
|
506
|
+
const typeLabel = describeSchema(field.schema);
|
|
507
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
508
|
+
flexDirection: "column",
|
|
509
|
+
flexShrink: 0,
|
|
510
|
+
children: [editing ? /* @__PURE__ */ jsxs(Box, {
|
|
511
|
+
flexDirection: "row",
|
|
512
|
+
flexShrink: 0,
|
|
513
|
+
children: [
|
|
514
|
+
/* @__PURE__ */ jsx(Text, {
|
|
515
|
+
color: focused ? "cyan" : void 0,
|
|
516
|
+
children: cursor
|
|
517
|
+
}),
|
|
518
|
+
/* @__PURE__ */ jsx(Text, {
|
|
519
|
+
bold: true,
|
|
520
|
+
children: namePadded
|
|
521
|
+
}),
|
|
522
|
+
/* @__PURE__ */ jsx(Text, {
|
|
523
|
+
dimColor: true,
|
|
524
|
+
children: optionalMark === "" ? "" : optionalMark + " "
|
|
525
|
+
}),
|
|
526
|
+
/* @__PURE__ */ jsx(Box, {
|
|
527
|
+
flexGrow: 1,
|
|
528
|
+
flexShrink: 1,
|
|
529
|
+
children: /* @__PURE__ */ jsx(TextEditor, {
|
|
530
|
+
model,
|
|
531
|
+
field,
|
|
532
|
+
value,
|
|
533
|
+
cls
|
|
534
|
+
})
|
|
535
|
+
}),
|
|
536
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
537
|
+
dimColor: true,
|
|
538
|
+
children: [" ", typeLabel]
|
|
539
|
+
})
|
|
540
|
+
]
|
|
541
|
+
}) : /* @__PURE__ */ jsxs(Text, { children: [
|
|
542
|
+
/* @__PURE__ */ jsx(Text, {
|
|
543
|
+
color: focused ? "cyan" : void 0,
|
|
544
|
+
children: cursor
|
|
545
|
+
}),
|
|
546
|
+
/* @__PURE__ */ jsx(Text, {
|
|
547
|
+
bold: true,
|
|
548
|
+
children: namePadded
|
|
549
|
+
}),
|
|
550
|
+
/* @__PURE__ */ jsx(Text, {
|
|
551
|
+
dimColor: true,
|
|
552
|
+
children: optionalMark + " "
|
|
553
|
+
}),
|
|
554
|
+
/* @__PURE__ */ jsx(Text, {
|
|
555
|
+
color: "yellow",
|
|
556
|
+
children: pad(formatValue(value, cls), VALUE_W)
|
|
557
|
+
}),
|
|
558
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
559
|
+
dimColor: true,
|
|
560
|
+
children: [" ", typeLabel]
|
|
561
|
+
})
|
|
562
|
+
] }), error ? /* @__PURE__ */ jsx(Box, {
|
|
563
|
+
marginLeft: 14,
|
|
564
|
+
flexShrink: 0,
|
|
565
|
+
children: /* @__PURE__ */ jsxs(Text, {
|
|
566
|
+
color: "red",
|
|
567
|
+
children: ["! ", error]
|
|
568
|
+
})
|
|
569
|
+
}) : null]
|
|
570
|
+
});
|
|
571
|
+
};
|
|
572
|
+
const TextEditor = ({ model, field, value, cls }) => {
|
|
573
|
+
const initial = textForEditing(value, cls.kind);
|
|
574
|
+
const [draft, setDraft] = React.useState(initial);
|
|
575
|
+
React.useEffect(() => {
|
|
576
|
+
setDraft(initial);
|
|
577
|
+
}, [initial]);
|
|
578
|
+
return /* @__PURE__ */ jsx(TextInput, {
|
|
579
|
+
value: draft,
|
|
580
|
+
onChange: setDraft,
|
|
581
|
+
onSubmit: (submitted) => {
|
|
582
|
+
const parsed = parseTypedValue(submitted, cls.kind);
|
|
583
|
+
if (parsed.ok) {
|
|
584
|
+
model.setField(field.name, parsed.value);
|
|
585
|
+
model.formEditing.set(false, void 0);
|
|
586
|
+
}
|
|
587
|
+
},
|
|
588
|
+
focus: true
|
|
589
|
+
});
|
|
590
|
+
};
|
|
591
|
+
function textForEditing(value, kind) {
|
|
592
|
+
if (value === void 0) return "";
|
|
593
|
+
if (kind === "string") return typeof value === "string" ? value : JSON.stringify(value);
|
|
594
|
+
if (kind === "json") return JSON.stringify(value);
|
|
595
|
+
return String(value);
|
|
596
|
+
}
|
|
597
|
+
function formatValue(value, cls) {
|
|
598
|
+
if (value === void 0) return "(unset)";
|
|
599
|
+
switch (cls.kind) {
|
|
600
|
+
case "boolean": return value ? "[x]" : "[ ]";
|
|
601
|
+
case "enum": return `< ${stringifyEnum(value)} >`;
|
|
602
|
+
case "string": return typeof value === "string" ? value : JSON.stringify(value);
|
|
603
|
+
case "json": {
|
|
604
|
+
const text = JSON.stringify(value);
|
|
605
|
+
return text.length > 40 ? text.slice(0, 37) + "..." : text;
|
|
606
|
+
}
|
|
607
|
+
default: return JSON.stringify(value);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
function stringifyEnum(value) {
|
|
611
|
+
return typeof value === "string" ? value : JSON.stringify(value);
|
|
612
|
+
}
|
|
613
|
+
/** Pad to `width` with spaces, or truncate with `…` if too long. */
|
|
614
|
+
function pad(text, width) {
|
|
615
|
+
if (text.length === width) return text;
|
|
616
|
+
if (text.length < width) return text + " ".repeat(width - text.length);
|
|
617
|
+
return text.slice(0, width - 1) + "…";
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region src/ui/App.tsx
|
|
621
|
+
const IDENTITY_HEADER_H = 1;
|
|
622
|
+
const FOOTER_H = 1;
|
|
623
|
+
const RESULT_H = 8;
|
|
624
|
+
/** Column chrome that is not list rows: top border + bottom border + title. */
|
|
625
|
+
const COLUMN_CHROME_H = 3;
|
|
626
|
+
/** Re-render on terminal resize so the height math tracks the live row count. */
|
|
627
|
+
function useTerminalRows() {
|
|
628
|
+
const { stdout } = useStdout();
|
|
629
|
+
const [rows, setRows] = React.useState(stdout.rows ?? 30);
|
|
630
|
+
React.useEffect(() => {
|
|
631
|
+
const onResize = () => setRows(stdout.rows ?? 30);
|
|
632
|
+
stdout.on("resize", onResize);
|
|
633
|
+
return () => {
|
|
634
|
+
stdout.off("resize", onResize);
|
|
635
|
+
};
|
|
636
|
+
}, [stdout]);
|
|
637
|
+
return rows;
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Miller-column TUI: three columns left → right (services, methods, form)
|
|
641
|
+
* plus a result pane at the bottom. ←/→ moves focus between columns, ↑/↓
|
|
642
|
+
* moves the cursor inside the focused column. Each cursor move in columns
|
|
643
|
+
* 0 / 1 immediately previews into the column to its right.
|
|
644
|
+
*/
|
|
645
|
+
const App = ({ model }) => {
|
|
646
|
+
const focusedColumn = useObservable(model.focusedColumn);
|
|
647
|
+
const editing = useObservable(model.formEditing);
|
|
648
|
+
const discoveryWarnings = useObservable(model.discoveryWarnings);
|
|
649
|
+
const termRows = useTerminalRows();
|
|
650
|
+
const { exit } = useApp();
|
|
651
|
+
useInput((input, key) => {
|
|
652
|
+
if (editing) return;
|
|
653
|
+
if (input === "q") {
|
|
654
|
+
exit();
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
if (key.leftArrow && focusedColumn > 0) {
|
|
658
|
+
model.focusColumn(focusedColumn - 1);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (key.rightArrow && focusedColumn < 2) {
|
|
662
|
+
model.focusColumn(focusedColumn + 1);
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
});
|
|
666
|
+
const headerHeight = IDENTITY_HEADER_H + discoveryWarnings.length;
|
|
667
|
+
const bodyHeight = Math.max(4, termRows - headerHeight - RESULT_H - FOOTER_H);
|
|
668
|
+
const listHeight = Math.max(1, bodyHeight - COLUMN_CHROME_H);
|
|
669
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
670
|
+
flexDirection: "column",
|
|
671
|
+
height: termRows,
|
|
672
|
+
children: [
|
|
673
|
+
/* @__PURE__ */ jsx(Header, {
|
|
674
|
+
model,
|
|
675
|
+
warnings: discoveryWarnings
|
|
676
|
+
}),
|
|
677
|
+
/* @__PURE__ */ jsxs(Box, {
|
|
678
|
+
height: bodyHeight,
|
|
679
|
+
children: [
|
|
680
|
+
/* @__PURE__ */ jsx(ServicesColumn, {
|
|
681
|
+
model,
|
|
682
|
+
focused: focusedColumn === 0,
|
|
683
|
+
height: bodyHeight,
|
|
684
|
+
listHeight
|
|
685
|
+
}),
|
|
686
|
+
/* @__PURE__ */ jsx(MethodsColumn, {
|
|
687
|
+
model,
|
|
688
|
+
focused: focusedColumn === 1,
|
|
689
|
+
height: bodyHeight,
|
|
690
|
+
listHeight
|
|
691
|
+
}),
|
|
692
|
+
/* @__PURE__ */ jsx(FormColumn, {
|
|
693
|
+
model,
|
|
694
|
+
focused: focusedColumn === 2,
|
|
695
|
+
height: bodyHeight,
|
|
696
|
+
listHeight
|
|
697
|
+
})
|
|
698
|
+
]
|
|
699
|
+
}),
|
|
700
|
+
/* @__PURE__ */ jsx(ResultPane, { model }),
|
|
701
|
+
/* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Text, {
|
|
702
|
+
dimColor: true,
|
|
703
|
+
children: "←/→: switch column | ↑/↓: move | enter: edit / submit | esc: cancel edit | q: quit"
|
|
704
|
+
}) })
|
|
705
|
+
]
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
/** "▲ N more" / "▼ N more" indicator row, shown only when items are hidden. */
|
|
709
|
+
const ScrollIndicator = ({ direction, count }) => {
|
|
710
|
+
if (count <= 0) return null;
|
|
711
|
+
return /* @__PURE__ */ jsx(Text, {
|
|
712
|
+
dimColor: true,
|
|
713
|
+
children: ` ${direction === "up" ? "▲" : "▼"} ${count} more`
|
|
714
|
+
});
|
|
715
|
+
};
|
|
716
|
+
const Header = ({ model, warnings }) => {
|
|
717
|
+
const identity = useObservable(model.identity);
|
|
718
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
719
|
+
flexDirection: "column",
|
|
720
|
+
children: [/* @__PURE__ */ jsxs(Box, { children: [/* @__PURE__ */ jsx(Text, {
|
|
721
|
+
dimColor: true,
|
|
722
|
+
children: "identity: "
|
|
723
|
+
}), /* @__PURE__ */ jsx(Text, { children: identity ?? "(resolving…)" })] }), warnings.map((warning) => /* @__PURE__ */ jsxs(Text, {
|
|
724
|
+
color: "yellow",
|
|
725
|
+
wrap: "truncate-end",
|
|
726
|
+
children: ["! ", warning]
|
|
727
|
+
}, warning))]
|
|
728
|
+
});
|
|
729
|
+
};
|
|
730
|
+
const ServicesColumn = ({ model, focused, height, listHeight }) => {
|
|
731
|
+
const result = useObservable(model.servicesPromise.promiseResult);
|
|
732
|
+
const selection = useObservable(model.selection);
|
|
733
|
+
useInput((_input, key) => {
|
|
734
|
+
if (key.upArrow) model.moveServiceCursor(-1);
|
|
735
|
+
else if (key.downArrow) model.moveServiceCursor(1);
|
|
736
|
+
else if (key.return) model.focusColumn(1);
|
|
737
|
+
}, { isActive: focused });
|
|
738
|
+
const services = !result || result.error ? [] : result.data ?? [];
|
|
739
|
+
const cursorIdx = selection ? services.findIndex((s) => s.serviceId === selection.serviceId && s.interfaceId === selection.interfaceId && s.isDefault === true === (selection.isDefault === true)) : -1;
|
|
740
|
+
const win = useScroll(services.length, Math.max(0, cursorIdx), listHeight);
|
|
741
|
+
return /* @__PURE__ */ jsx(Column, {
|
|
742
|
+
title: "Services",
|
|
743
|
+
focused,
|
|
744
|
+
width: "25%",
|
|
745
|
+
height,
|
|
746
|
+
children: !result ? /* @__PURE__ */ jsx(Text, {
|
|
747
|
+
dimColor: true,
|
|
748
|
+
children: "Loading…"
|
|
749
|
+
}) : result.error ? /* @__PURE__ */ jsxs(Text, {
|
|
750
|
+
color: "red",
|
|
751
|
+
children: ["Error: ", String(result.error)]
|
|
752
|
+
}) : services.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
753
|
+
dimColor: true,
|
|
754
|
+
children: "(no services)"
|
|
755
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
756
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
757
|
+
direction: "up",
|
|
758
|
+
count: win.above
|
|
759
|
+
}),
|
|
760
|
+
services.slice(win.start, win.end).map((s) => {
|
|
761
|
+
const isCursor = !!selection && selection.serviceId === s.serviceId && selection.interfaceId === s.interfaceId && selection.isDefault === true === (s.isDefault === true);
|
|
762
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
763
|
+
color: isCursor ? focused ? "cyan" : "white" : void 0,
|
|
764
|
+
wrap: "truncate-end",
|
|
765
|
+
children: [(isCursor ? "› " : " ") + (s.isDefault === true ? "default " : s.serviceId ? `${s.serviceId} ` : ""), /* @__PURE__ */ jsx(Text, {
|
|
766
|
+
dimColor: true,
|
|
767
|
+
children: s.interfaceId
|
|
768
|
+
})]
|
|
769
|
+
}, `${s.isDefault === true ? "default" : s.serviceId}/${s.interfaceId}`);
|
|
770
|
+
}),
|
|
771
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
772
|
+
direction: "down",
|
|
773
|
+
count: win.below
|
|
774
|
+
})
|
|
775
|
+
] })
|
|
776
|
+
});
|
|
777
|
+
};
|
|
778
|
+
const MethodsColumn = ({ model, focused, height, listHeight }) => {
|
|
779
|
+
const selection = useObservable(model.selection);
|
|
780
|
+
const schemaState = useObservable(model.currentSchemaState);
|
|
781
|
+
const methods = useObservable(model.currentMethods);
|
|
782
|
+
useInput((_input, key) => {
|
|
783
|
+
if (key.upArrow) model.moveMethodCursor(-1);
|
|
784
|
+
else if (key.downArrow) model.moveMethodCursor(1);
|
|
785
|
+
else if (key.return) model.focusColumn(2);
|
|
786
|
+
}, { isActive: focused });
|
|
787
|
+
const cursorIdx = selection?.methodName !== void 0 ? methods.findIndex((m) => m.name === selection.methodName) : -1;
|
|
788
|
+
const win = useScroll(methods.length, Math.max(0, cursorIdx), listHeight);
|
|
789
|
+
return /* @__PURE__ */ jsx(Column, {
|
|
790
|
+
title: "Methods",
|
|
791
|
+
focused,
|
|
792
|
+
width: "25%",
|
|
793
|
+
height,
|
|
794
|
+
children: !selection ? /* @__PURE__ */ jsx(Text, {
|
|
795
|
+
dimColor: true,
|
|
796
|
+
children: "(select a service)"
|
|
797
|
+
}) : schemaState.kind === "loading" ? /* @__PURE__ */ jsx(Text, {
|
|
798
|
+
dimColor: true,
|
|
799
|
+
children: "Loading…"
|
|
800
|
+
}) : schemaState.kind === "error" ? /* @__PURE__ */ jsxs(Text, {
|
|
801
|
+
color: "red",
|
|
802
|
+
children: ["Error: ", String(schemaState.error)]
|
|
803
|
+
}) : methods.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
804
|
+
dimColor: true,
|
|
805
|
+
children: "(no methods)"
|
|
806
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
807
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
808
|
+
direction: "up",
|
|
809
|
+
count: win.above
|
|
810
|
+
}),
|
|
811
|
+
methods.slice(win.start, win.end).map((m) => {
|
|
812
|
+
const isCursor = selection.methodName === m.name;
|
|
813
|
+
return /* @__PURE__ */ jsxs(Text, {
|
|
814
|
+
color: isCursor ? focused ? "cyan" : "white" : void 0,
|
|
815
|
+
wrap: "truncate-end",
|
|
816
|
+
children: [
|
|
817
|
+
isCursor ? "› " : " ",
|
|
818
|
+
/* @__PURE__ */ jsx(Text, {
|
|
819
|
+
dimColor: true,
|
|
820
|
+
children: m.result === void 0 ? "notify " : "req "
|
|
821
|
+
}),
|
|
822
|
+
m.name,
|
|
823
|
+
m.serverStream !== void 0 ? /* @__PURE__ */ jsx(Text, {
|
|
824
|
+
color: "magenta",
|
|
825
|
+
children: " ↓stream"
|
|
826
|
+
}) : null
|
|
827
|
+
]
|
|
828
|
+
}, m.name);
|
|
829
|
+
}),
|
|
830
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
831
|
+
direction: "down",
|
|
832
|
+
count: win.below
|
|
833
|
+
})
|
|
834
|
+
] })
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
const FormColumn = ({ model, focused, height, listHeight }) => {
|
|
838
|
+
const selection = useObservable(model.selection);
|
|
839
|
+
const schemaState = useObservable(model.currentSchemaState);
|
|
840
|
+
return /* @__PURE__ */ jsx(Column, {
|
|
841
|
+
title: "Form",
|
|
842
|
+
focused,
|
|
843
|
+
flexGrow: 1,
|
|
844
|
+
height,
|
|
845
|
+
children: !selection ? /* @__PURE__ */ jsx(Text, {
|
|
846
|
+
dimColor: true,
|
|
847
|
+
children: "(select a service first)"
|
|
848
|
+
}) : selection.methodName === void 0 ? /* @__PURE__ */ jsx(Text, {
|
|
849
|
+
dimColor: true,
|
|
850
|
+
children: "(select a method first)"
|
|
851
|
+
}) : schemaState.kind === "loading" ? /* @__PURE__ */ jsx(Text, {
|
|
852
|
+
dimColor: true,
|
|
853
|
+
children: "Loading…"
|
|
854
|
+
}) : schemaState.kind === "loaded" && schemaState.method ? /* @__PURE__ */ jsx(MethodForm, {
|
|
855
|
+
model,
|
|
856
|
+
method: schemaState.method,
|
|
857
|
+
focused,
|
|
858
|
+
listHeight
|
|
859
|
+
}) : schemaState.kind === "error" ? /* @__PURE__ */ jsxs(Text, {
|
|
860
|
+
color: "red",
|
|
861
|
+
children: ["Error: ", String(schemaState.error)]
|
|
862
|
+
}) : /* @__PURE__ */ jsxs(Text, {
|
|
863
|
+
color: "red",
|
|
864
|
+
children: [
|
|
865
|
+
"Method \"",
|
|
866
|
+
selection.methodName,
|
|
867
|
+
"\" not in schema"
|
|
868
|
+
]
|
|
869
|
+
})
|
|
870
|
+
});
|
|
871
|
+
};
|
|
872
|
+
const MethodForm = ({ model, method, focused, listHeight }) => {
|
|
873
|
+
const fields = useObservable(model.currentFields);
|
|
874
|
+
const formValues = useObservable(model.formValues);
|
|
875
|
+
const errors = useObservable(model.formErrors);
|
|
876
|
+
const canSubmit = useObservable(model.canSubmit);
|
|
877
|
+
const cursor = useObservable(model.formCursor);
|
|
878
|
+
const editing = useObservable(model.formEditing);
|
|
879
|
+
const submitRowIdx = fields.length;
|
|
880
|
+
const safeCursor = Math.max(0, Math.min(submitRowIdx, cursor));
|
|
881
|
+
const focusedField = safeCursor < submitRowIdx ? fields[safeCursor] : void 0;
|
|
882
|
+
const headerLines = 1 + (method.summary ? 1 : 0) + 1;
|
|
883
|
+
const fieldsHeight = Math.max(1, listHeight - headerLines - 2);
|
|
884
|
+
const win = useScroll(fields.length, Math.min(safeCursor, Math.max(0, fields.length - 1)), fieldsHeight);
|
|
885
|
+
useInput((input, key) => {
|
|
886
|
+
if (key.upArrow) {
|
|
887
|
+
model.formCursor.set(Math.max(0, safeCursor - 1), void 0);
|
|
888
|
+
return;
|
|
889
|
+
}
|
|
890
|
+
if (key.downArrow) {
|
|
891
|
+
model.formCursor.set(Math.min(submitRowIdx, safeCursor + 1), void 0);
|
|
892
|
+
return;
|
|
893
|
+
}
|
|
894
|
+
if (focusedField) {
|
|
895
|
+
const cls = classifyField(focusedField.schema);
|
|
896
|
+
const current = formValues[focusedField.name];
|
|
897
|
+
if (cls.kind === "boolean" && input === " ") {
|
|
898
|
+
model.setField(focusedField.name, !current);
|
|
899
|
+
return;
|
|
900
|
+
}
|
|
901
|
+
if (cls.kind === "enum") {
|
|
902
|
+
if (key.leftArrow || key.rightArrow) {
|
|
903
|
+
const dir = key.rightArrow ? 1 : -1;
|
|
904
|
+
const seed = current === void 0 ? defaultValueFor(cls) : current;
|
|
905
|
+
model.setField(focusedField.name, cycleEnum(cls.enumValues ?? [], seed, dir));
|
|
906
|
+
return;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
if (key.return) {
|
|
910
|
+
if (cls.kind === "boolean") {
|
|
911
|
+
model.setField(focusedField.name, !current);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
if (cls.kind === "enum") {
|
|
915
|
+
const seed = current === void 0 ? defaultValueFor(cls) : current;
|
|
916
|
+
model.setField(focusedField.name, cycleEnum(cls.enumValues ?? [], seed, 1));
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
if (current === void 0) model.setField(focusedField.name, defaultValueFor(cls));
|
|
920
|
+
model.formEditing.set(true, void 0);
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
if (input === "x" && focusedField.required === false && current !== void 0) {
|
|
924
|
+
model.setField(focusedField.name, void 0);
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
} else if (safeCursor === submitRowIdx && key.return) {
|
|
928
|
+
if (canSubmit) model.submit();
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
}, { isActive: focused && !editing });
|
|
932
|
+
useInput((_input, key) => {
|
|
933
|
+
if (key.escape) model.formEditing.set(false, void 0);
|
|
934
|
+
}, { isActive: focused && editing });
|
|
935
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
936
|
+
flexDirection: "column",
|
|
937
|
+
flexShrink: 0,
|
|
938
|
+
children: [
|
|
939
|
+
/* @__PURE__ */ jsx(Text, {
|
|
940
|
+
bold: true,
|
|
941
|
+
children: (method.result === void 0 ? "notify " : "request ") + selection?.methodName
|
|
942
|
+
}),
|
|
943
|
+
method.summary ? /* @__PURE__ */ jsx(Text, {
|
|
944
|
+
dimColor: true,
|
|
945
|
+
children: method.summary
|
|
946
|
+
}) : null,
|
|
947
|
+
/* @__PURE__ */ jsx(Box, {
|
|
948
|
+
marginTop: 1,
|
|
949
|
+
flexDirection: "column",
|
|
950
|
+
flexShrink: 0,
|
|
951
|
+
children: fields.length === 0 ? /* @__PURE__ */ jsx(Text, {
|
|
952
|
+
dimColor: true,
|
|
953
|
+
children: "(no parameters)"
|
|
954
|
+
}) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
955
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
956
|
+
direction: "up",
|
|
957
|
+
count: win.above
|
|
958
|
+
}),
|
|
959
|
+
fields.slice(win.start, win.end).map((f, i) => {
|
|
960
|
+
const idx = win.start + i;
|
|
961
|
+
return /* @__PURE__ */ jsx(FieldRow, {
|
|
962
|
+
model,
|
|
963
|
+
field: f,
|
|
964
|
+
value: formValues[f.name],
|
|
965
|
+
focused: focused && safeCursor === idx,
|
|
966
|
+
editing: editing && safeCursor === idx,
|
|
967
|
+
error: errors.get(f.name)
|
|
968
|
+
}, f.name);
|
|
969
|
+
}),
|
|
970
|
+
/* @__PURE__ */ jsx(ScrollIndicator, {
|
|
971
|
+
direction: "down",
|
|
972
|
+
count: win.below
|
|
973
|
+
})
|
|
974
|
+
] })
|
|
975
|
+
}),
|
|
976
|
+
/* @__PURE__ */ jsx(Box, {
|
|
977
|
+
marginTop: 1,
|
|
978
|
+
flexShrink: 0,
|
|
979
|
+
children: /* @__PURE__ */ jsx(Text, {
|
|
980
|
+
color: canSubmit ? "green" : "gray",
|
|
981
|
+
children: submitLabel(focused && safeCursor === submitRowIdx, canSubmit)
|
|
982
|
+
})
|
|
983
|
+
})
|
|
984
|
+
]
|
|
985
|
+
});
|
|
986
|
+
};
|
|
987
|
+
function submitLabel(focused, canSubmit) {
|
|
988
|
+
return `${focused ? "› " : " "}[Submit${canSubmit ? "" : " — fix errors first"}]`;
|
|
989
|
+
}
|
|
990
|
+
const Column = ({ title, focused, children, width, flexGrow, height }) => {
|
|
991
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
992
|
+
flexDirection: "column",
|
|
993
|
+
width,
|
|
994
|
+
flexGrow,
|
|
995
|
+
height,
|
|
996
|
+
overflow: "hidden",
|
|
997
|
+
borderStyle: "single",
|
|
998
|
+
borderColor: focused ? "cyan" : void 0,
|
|
999
|
+
paddingX: 1,
|
|
1000
|
+
children: [/* @__PURE__ */ jsx(Text, {
|
|
1001
|
+
bold: true,
|
|
1002
|
+
children: title
|
|
1003
|
+
}), children]
|
|
1004
|
+
});
|
|
1005
|
+
};
|
|
1006
|
+
const ResultPane = ({ model }) => {
|
|
1007
|
+
const promise = useObservable(model.lastCall);
|
|
1008
|
+
const result = useObservable(promise ? promise.promiseResult : NO_RESULT);
|
|
1009
|
+
const chunks = useObservable(model.streamChunks);
|
|
1010
|
+
return /* @__PURE__ */ jsxs(Box, {
|
|
1011
|
+
flexDirection: "column",
|
|
1012
|
+
height: RESULT_H,
|
|
1013
|
+
overflow: "hidden",
|
|
1014
|
+
borderStyle: "single",
|
|
1015
|
+
paddingX: 1,
|
|
1016
|
+
children: [
|
|
1017
|
+
/* @__PURE__ */ jsx(Text, {
|
|
1018
|
+
bold: true,
|
|
1019
|
+
children: "Result"
|
|
1020
|
+
}),
|
|
1021
|
+
chunks.length > 0 ? /* @__PURE__ */ jsx(Box, {
|
|
1022
|
+
flexDirection: "column",
|
|
1023
|
+
children: chunks.map((c, i) => /* @__PURE__ */ jsx(Text, {
|
|
1024
|
+
dimColor: true,
|
|
1025
|
+
children: "│ " + formatChunk(c)
|
|
1026
|
+
}, i))
|
|
1027
|
+
}) : null,
|
|
1028
|
+
!promise ? /* @__PURE__ */ jsx(Text, {
|
|
1029
|
+
dimColor: true,
|
|
1030
|
+
children: "(no calls yet)"
|
|
1031
|
+
}) : !result ? /* @__PURE__ */ jsx(Text, {
|
|
1032
|
+
dimColor: true,
|
|
1033
|
+
children: chunks.length > 0 ? "Streaming…" : "Calling…"
|
|
1034
|
+
}) : result.error ? /* @__PURE__ */ jsxs(Text, {
|
|
1035
|
+
color: "red",
|
|
1036
|
+
children: ["Error: ", formatError(result.error)]
|
|
1037
|
+
}) : /* @__PURE__ */ jsxs(Text, { children: [
|
|
1038
|
+
JSON.stringify(result.data?.result),
|
|
1039
|
+
" ",
|
|
1040
|
+
/* @__PURE__ */ jsxs(Text, {
|
|
1041
|
+
dimColor: true,
|
|
1042
|
+
children: [result.data?.latencyMs.toFixed(1), "ms"]
|
|
1043
|
+
})
|
|
1044
|
+
] })
|
|
1045
|
+
]
|
|
1046
|
+
});
|
|
1047
|
+
};
|
|
1048
|
+
const NO_RESULT = observableValue("App.NO_RESULT", void 0);
|
|
1049
|
+
function formatChunk(c) {
|
|
1050
|
+
return typeof c === "string" ? c : JSON.stringify(c);
|
|
1051
|
+
}
|
|
1052
|
+
function formatError(e) {
|
|
1053
|
+
if (e instanceof Error) return e.message;
|
|
1054
|
+
return String(e);
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/ui/runUi.tsx
|
|
1058
|
+
async function runUi(opts) {
|
|
1059
|
+
if (isHubEndpoint(opts.endpoint)) await _runUiReconnecting(opts.endpoint, opts.principalSpec);
|
|
1060
|
+
else await _runUiOnce(opts.endpoint, opts.principalSpec);
|
|
1061
|
+
}
|
|
1062
|
+
/** Non-Hub endpoints: a single connection, no redial. */
|
|
1063
|
+
async function _runUiOnce(endpoint, principalSpec) {
|
|
1064
|
+
const conn = await connect(endpoint);
|
|
1065
|
+
const identity = _isRawEndpoint(endpoint) ? "unsigned (raw endpoint)" : _formatIdentity(await setupSigning(conn.channel, conn.signing, principalSpec, { negotiateHubCaps: false }));
|
|
1066
|
+
const model = new UiModel(conn.channel);
|
|
1067
|
+
model.identity.set(identity, void 0);
|
|
1068
|
+
const instance = render(/* @__PURE__ */ jsx(App, { model }));
|
|
1069
|
+
try {
|
|
1070
|
+
await instance.waitUntilExit();
|
|
1071
|
+
} finally {
|
|
1072
|
+
model.dispose();
|
|
1073
|
+
conn.close();
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
function _isRawEndpoint(endpoint) {
|
|
1077
|
+
return endpoint.kind === "ws-no-init" || endpoint.kind === "socket" && endpoint.brokerMode === "raw";
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* Hub / env connection: keep the UI alive across socket drops. A stable
|
|
1081
|
+
* {@link SwappableSender} backs the {@link UiModel}; on every (re)connect we
|
|
1082
|
+
* open a fresh hub channel, install signing, and point the swappable sender at
|
|
1083
|
+
* the new signed channel. The TUI is rendered once, after the first connect.
|
|
1084
|
+
*/
|
|
1085
|
+
async function _runUiReconnecting(endpoint, principalSpec) {
|
|
1086
|
+
const swappable = new SwappableSender();
|
|
1087
|
+
let model;
|
|
1088
|
+
const hubEndpoint = endpoint.kind === "ws" ? endpoint.url : endpoint.path;
|
|
1089
|
+
const handle = ChannelConnector.expBackoff(() => openHubChannel({
|
|
1090
|
+
endpoint: hubEndpoint,
|
|
1091
|
+
token: endpoint.token ?? ""
|
|
1092
|
+
})).keepConnected(async ({ channel }) => {
|
|
1093
|
+
const signing = {};
|
|
1094
|
+
const signed = SigningSender.wrapChannel(channel, signing).sender;
|
|
1095
|
+
const session = await setupSigning(signed, signing, principalSpec, { negotiateHubCaps: true });
|
|
1096
|
+
swappable.setTarget(signed);
|
|
1097
|
+
if (!model) {
|
|
1098
|
+
model = new UiModel(swappable);
|
|
1099
|
+
model.identity.set(_formatIdentity(session), void 0);
|
|
1100
|
+
render(/* @__PURE__ */ jsx(App, { model })).waitUntilExit().finally(() => handle.stop());
|
|
1101
|
+
} else model.identity.set(_formatIdentity(session), void 0);
|
|
1102
|
+
});
|
|
1103
|
+
try {
|
|
1104
|
+
await handle.done;
|
|
1105
|
+
} finally {
|
|
1106
|
+
model?.dispose();
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
/** One-line identity label for the TUI header (source + truncated nodeId). */
|
|
1110
|
+
function _formatIdentity(session) {
|
|
1111
|
+
return formatPrincipalSource(session.principalSource, session.principal.identity.publicSigningIdentity.principal);
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* An {@link IRequestSender} whose delegate can be swapped at runtime. Lets the
|
|
1115
|
+
* {@link UiModel} hold one stable channel reference while the underlying signed
|
|
1116
|
+
* channel is replaced on each reconnect. Calls issued while disconnected reject.
|
|
1117
|
+
*/
|
|
1118
|
+
var SwappableSender = class {
|
|
1119
|
+
_target;
|
|
1120
|
+
setTarget(target) {
|
|
1121
|
+
this._target = target;
|
|
1122
|
+
}
|
|
1123
|
+
_require() {
|
|
1124
|
+
if (!this._target) throw new RpcError("not connected", -32e3);
|
|
1125
|
+
return this._target;
|
|
1126
|
+
}
|
|
1127
|
+
sendRequest(method, params, opts) {
|
|
1128
|
+
return this._require().sendRequest(method, params, opts);
|
|
1129
|
+
}
|
|
1130
|
+
sendNotification(method, params, opts) {
|
|
1131
|
+
return this._require().sendNotification(method, params, opts);
|
|
1132
|
+
}
|
|
1133
|
+
sendRequestWithStream(method, params, opts) {
|
|
1134
|
+
return this._require().sendRequestWithStream(method, params, opts);
|
|
1135
|
+
}
|
|
1136
|
+
close() {
|
|
1137
|
+
this._target?.close();
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
//#endregion
|
|
1141
|
+
export { runUi };
|
|
1142
|
+
|
|
1143
|
+
//# sourceMappingURL=runUi-D-8LMU4F.js.map
|