@qilin-agent/client-ui-input-trigger 0.0.0-stage → 3.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.i18n.yaml +40 -0
- package/README.md +88 -2
- package/README.zh.md +89 -0
- package/lib/client.js +1927 -0
- package/lib/index.js +11 -0
- package/lib/types/client/MenuView.d.ts +11 -0
- package/lib/types/client/contract.d.ts +25 -0
- package/lib/types/client/controller.d.ts +219 -0
- package/lib/types/client/index.d.ts +32 -0
- package/lib/types/client/locales.d.ts +32 -0
- package/lib/types/client/service.d.ts +42 -0
- package/lib/types/client/slots.d.ts +33 -0
- package/lib/types/core/contract.d.ts +77 -0
- package/lib/types/core/detect.d.ts +19 -0
- package/lib/types/core/menu.d.ts +53 -0
- package/lib/types/index.d.ts +9 -0
- package/lib/types/types.d.ts +214 -0
- package/package.json +70 -3
package/lib/client.js
ADDED
|
@@ -0,0 +1,1927 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: "@qilin-agent/client-ui-input-trigger",
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} };
|
|
5
|
+
var exports = module.exports;
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
|
+
let _qilin_agent_kylin = require("@qilin-agent/kylin");
|
|
8
|
+
let _qilin_agent_client_store = require("@qilin-agent/client-store");
|
|
9
|
+
let react_jsx_runtime = require("react/jsx-runtime");
|
|
10
|
+
let react = require("react");
|
|
11
|
+
let _qilin_agent_client_ui_primitives = require("@qilin-agent/client-ui-primitives");
|
|
12
|
+
//#region ../../util/values/src/partial-json.ts
|
|
13
|
+
/**
|
|
14
|
+
* Lazily scanned view of one JSON object's top-level fields, built from text
|
|
15
|
+
* that may still be streaming or from an already parsed object. Nothing is
|
|
16
|
+
* scanned until a reader asks; the view remembers every question it answered
|
|
17
|
+
* and reports changed answers when the owner refreshes for publication.
|
|
18
|
+
* Used for model tool-call arguments: a row reads the fields it
|
|
19
|
+
* cares about at whatever granularity it displays, at every stage of the call.
|
|
20
|
+
* @module @qilin-agent/util-values/src/partial-json
|
|
21
|
+
*/
|
|
22
|
+
const SIMPLE_ESCAPES = {
|
|
23
|
+
"\"": "\"",
|
|
24
|
+
"\\": "\\",
|
|
25
|
+
"/": "/",
|
|
26
|
+
b: "\b",
|
|
27
|
+
f: "\f",
|
|
28
|
+
n: "\n",
|
|
29
|
+
r: "\r",
|
|
30
|
+
t: " "
|
|
31
|
+
};
|
|
32
|
+
const CONTENT_ESCAPE = /[\\\u0000-\u001f]/u;
|
|
33
|
+
function isWhitespace(c) {
|
|
34
|
+
return c === " " || c === "\n" || c === "\r" || c === " ";
|
|
35
|
+
}
|
|
36
|
+
function isHex(c) {
|
|
37
|
+
return c >= "0" && c <= "9" || c >= "a" && c <= "f" || c >= "A" && c <= "F";
|
|
38
|
+
}
|
|
39
|
+
(class PartialArguments {
|
|
40
|
+
/** The view of a call with no arguments available. */
|
|
41
|
+
static EMPTY = PartialArguments.fromObject({});
|
|
42
|
+
/**
|
|
43
|
+
* View finished argument text without scanning it until a reader asks.
|
|
44
|
+
* @param text - the complete argument JSON text.
|
|
45
|
+
* @returns a sealed view.
|
|
46
|
+
*/
|
|
47
|
+
static fromText(text) {
|
|
48
|
+
const view = new PartialArguments();
|
|
49
|
+
view.append(text);
|
|
50
|
+
view.sealed = true;
|
|
51
|
+
return view;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* View an already parsed argument payload, such as a PTC dispatch object.
|
|
55
|
+
* @param value - the parsed argument value.
|
|
56
|
+
* @returns a sealed view; a non-object payload has no fields.
|
|
57
|
+
*/
|
|
58
|
+
static fromObject(value) {
|
|
59
|
+
const view = new PartialArguments();
|
|
60
|
+
view.object = typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
61
|
+
view.sealed = true;
|
|
62
|
+
return view;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The source: text so far or a parsed object, plus whether it can still grow.
|
|
66
|
+
* These are the only enumerable fields, so two views over the same source
|
|
67
|
+
* compare equal structurally however far each has been read.
|
|
68
|
+
*/
|
|
69
|
+
chunks = [];
|
|
70
|
+
object;
|
|
71
|
+
sealed = false;
|
|
72
|
+
#ends = [];
|
|
73
|
+
#size = 0;
|
|
74
|
+
#consumed = 0;
|
|
75
|
+
#mode = "root";
|
|
76
|
+
#escape = false;
|
|
77
|
+
#keyStart = 0;
|
|
78
|
+
#keyEscaped = false;
|
|
79
|
+
#key = "";
|
|
80
|
+
#current = null;
|
|
81
|
+
#nestedEnds = [];
|
|
82
|
+
#nestedInString = false;
|
|
83
|
+
#invalidAt;
|
|
84
|
+
#invalidValue = false;
|
|
85
|
+
#entries = /* @__PURE__ */ new Map();
|
|
86
|
+
#order = [];
|
|
87
|
+
#reads = /* @__PURE__ */ new Map();
|
|
88
|
+
/** Whether this view rejects further appends; does not scan text or register reads. */
|
|
89
|
+
get isSealed() {
|
|
90
|
+
return this.sealed;
|
|
91
|
+
}
|
|
92
|
+
/** Whether indexing or a content read found invalid JSON; unread value contents are not validated. */
|
|
93
|
+
get invalid() {
|
|
94
|
+
this.scan();
|
|
95
|
+
return this.#mode === "invalid" || this.#invalidValue;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Retain streamed argument text without scanning or comparing observed answers.
|
|
99
|
+
* @param fragment - the text following every fragment appended before.
|
|
100
|
+
*/
|
|
101
|
+
append(fragment) {
|
|
102
|
+
if (this.sealed) throw new Error("PartialArguments: cannot append to a sealed view");
|
|
103
|
+
if (fragment.length === 0) return;
|
|
104
|
+
this.chunks.push(fragment);
|
|
105
|
+
this.#size += fragment.length;
|
|
106
|
+
this.#ends.push(this.#size);
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Reconcile a streamed prefix with authoritative complete text without joining the fragments.
|
|
110
|
+
* @param text - the final argument text, which replaces missing or conflicting deltas.
|
|
111
|
+
* @returns this view sealed with its caches retained when every character matches; otherwise a new sealed view.
|
|
112
|
+
*/
|
|
113
|
+
settle(text) {
|
|
114
|
+
if (this.object !== void 0 || text.length !== this.#size) return PartialArguments.fromText(text);
|
|
115
|
+
let offset = 0;
|
|
116
|
+
for (const chunk of this.chunks) {
|
|
117
|
+
if (!text.startsWith(chunk, offset)) return PartialArguments.fromText(text);
|
|
118
|
+
offset += chunk.length;
|
|
119
|
+
}
|
|
120
|
+
this.chunks = text.length === 0 ? [] : [text];
|
|
121
|
+
this.#ends = text.length === 0 ? [] : [text.length];
|
|
122
|
+
this.sealed = true;
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Compare observed answers and advance their publication baseline. Unread views remain unscanned.
|
|
127
|
+
* @returns whether any observed answer changed since its first read or the preceding refresh.
|
|
128
|
+
*/
|
|
129
|
+
refresh() {
|
|
130
|
+
if (this.#reads.size === 0) return false;
|
|
131
|
+
this.scan();
|
|
132
|
+
let changed = false;
|
|
133
|
+
let completions = false;
|
|
134
|
+
for (const read of this.#reads.values()) {
|
|
135
|
+
if (read.completion) {
|
|
136
|
+
completions = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
changed = this.refreshRead(read) || changed;
|
|
140
|
+
}
|
|
141
|
+
if (completions) {
|
|
142
|
+
for (const read of this.#reads.values()) if (read.completion) changed = this.refreshRead(read) || changed;
|
|
143
|
+
}
|
|
144
|
+
if (this.sealed) this.#reads.clear();
|
|
145
|
+
return changed;
|
|
146
|
+
}
|
|
147
|
+
refreshRead(read) {
|
|
148
|
+
const now = read.answer();
|
|
149
|
+
if (Object.is(now, read.last)) return false;
|
|
150
|
+
read.last = now;
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Check whether no further fields can arrive.
|
|
155
|
+
* @returns whether the outer object closed, indexing failed, or the view is sealed; unread values are not validated.
|
|
156
|
+
*/
|
|
157
|
+
closed() {
|
|
158
|
+
return this.remember("closed", "", () => this.closedNow());
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* List discovered fields in first-appearance order.
|
|
162
|
+
* @returns top-level keys seen so far, in first-appearance order.
|
|
163
|
+
*/
|
|
164
|
+
keys() {
|
|
165
|
+
return this.remember("keys", "", () => this.keysNow(), (keys) => keys.length);
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Check whether a top-level field has appeared.
|
|
169
|
+
* @param key - argument name.
|
|
170
|
+
* @returns whether the field has appeared (a string opened or another value began).
|
|
171
|
+
*/
|
|
172
|
+
has(key) {
|
|
173
|
+
return this.remember("has", key, () => this.hasNow(key));
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Check whether a field's closing delimiter has arrived, without validating its contents.
|
|
177
|
+
* @param key - argument name.
|
|
178
|
+
* @returns whether its delimiter arrived and no content reader has reported an error for this value.
|
|
179
|
+
*/
|
|
180
|
+
complete(key) {
|
|
181
|
+
return this.remember("complete", key, () => this.completeNow(key));
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Read string length without materializing its text.
|
|
185
|
+
* @param key - argument name.
|
|
186
|
+
* @param options - change granularity for a streaming string.
|
|
187
|
+
* @returns decoded UTF-16 length of the string field so far; undefined when absent or not a string.
|
|
188
|
+
*/
|
|
189
|
+
stringLength(key, options) {
|
|
190
|
+
const step = Math.max(1, Math.floor(options?.step ?? 1));
|
|
191
|
+
const offset = options?.offset ?? 0;
|
|
192
|
+
return this.remember(`length:${step}:${offset}`, key, () => this.lengthNow(key), (length) => length === void 0 ? void 0 : Math.ceil((length + offset) / step));
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Check a string against a decoded UTF-16 length limit without materializing it.
|
|
196
|
+
* @param key - argument name.
|
|
197
|
+
* @param maxLength - decoded UTF-16 limit, floored to at least zero.
|
|
198
|
+
* @returns whether the string is longer than the limit; false when absent or not a string.
|
|
199
|
+
*/
|
|
200
|
+
stringExceeds(key, maxLength) {
|
|
201
|
+
const limit = Math.max(0, Math.floor(maxLength));
|
|
202
|
+
return this.remember(`exceeds:${limit}`, key, () => (this.lengthNow(key, limit + 1) ?? 0) > limit);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Read a decoded string, including a streaming prefix.
|
|
206
|
+
* @param key - argument name.
|
|
207
|
+
* @returns the string field's decoded text so far; undefined when absent or not a string.
|
|
208
|
+
*/
|
|
209
|
+
text(key) {
|
|
210
|
+
return this.remember("text", key, () => this.textNow(key));
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Read at most the first decoded UTF-16 units of a string.
|
|
214
|
+
* @param key - argument name.
|
|
215
|
+
* @param maxLength - maximum decoded UTF-16 length, floored to at least one.
|
|
216
|
+
* @returns the bounded string prefix; undefined when absent or not a string.
|
|
217
|
+
*/
|
|
218
|
+
textPrefix(key, maxLength) {
|
|
219
|
+
const limit = Math.max(1, Math.floor(maxLength));
|
|
220
|
+
return this.remember(`prefix:${limit}`, key, () => this.textPrefixNow(key, limit));
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Read a completed non-string argument.
|
|
224
|
+
* @param key - argument name.
|
|
225
|
+
* @returns the parsed non-string value once it closed; undefined while open, absent, or a string.
|
|
226
|
+
*/
|
|
227
|
+
value(key) {
|
|
228
|
+
return this.remember("value", key, () => this.valueNow(key));
|
|
229
|
+
}
|
|
230
|
+
/** Answer a question and, on a streaming view, remember it for change detection. */
|
|
231
|
+
remember(kind, key, read, comparison) {
|
|
232
|
+
this.scan();
|
|
233
|
+
const result = read();
|
|
234
|
+
if (!this.sealed) {
|
|
235
|
+
const id = `${kind}/${key}`;
|
|
236
|
+
if (!this.#reads.has(id)) this.#reads.set(id, {
|
|
237
|
+
completion: kind === "complete",
|
|
238
|
+
answer: comparison === void 0 ? read : () => comparison(read()),
|
|
239
|
+
last: comparison === void 0 ? result : comparison(result)
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
closedNow() {
|
|
245
|
+
return this.sealed || this.#mode === "closed" || this.#mode === "invalid";
|
|
246
|
+
}
|
|
247
|
+
keysNow() {
|
|
248
|
+
return this.object === void 0 ? this.#order : Object.keys(this.object);
|
|
249
|
+
}
|
|
250
|
+
hasNow(key) {
|
|
251
|
+
return this.object === void 0 ? this.#entries.has(key) : Object.hasOwn(this.object, key);
|
|
252
|
+
}
|
|
253
|
+
completeNow(key) {
|
|
254
|
+
if (this.object !== void 0) return Object.hasOwn(this.object, key);
|
|
255
|
+
const entry = this.#entries.get(key);
|
|
256
|
+
return entry !== void 0 && entry.end >= 0 && (entry.kind === "string" ? entry.invalidAt === void 0 : !entry.invalid);
|
|
257
|
+
}
|
|
258
|
+
lengthNow(key, limit = Number.POSITIVE_INFINITY) {
|
|
259
|
+
if (this.object !== void 0) {
|
|
260
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
261
|
+
return typeof field === "string" ? field.length : void 0;
|
|
262
|
+
}
|
|
263
|
+
const entry = this.#entries.get(key);
|
|
264
|
+
if (entry?.kind !== "string") return void 0;
|
|
265
|
+
if (entry.text !== void 0 && entry.text.at === entry.end) return entry.text.length;
|
|
266
|
+
const read = entry.length ??= {
|
|
267
|
+
at: entry.start,
|
|
268
|
+
length: 0,
|
|
269
|
+
text: ""
|
|
270
|
+
};
|
|
271
|
+
this.readString(entry, read, limit, false);
|
|
272
|
+
return read.length;
|
|
273
|
+
}
|
|
274
|
+
textNow(key) {
|
|
275
|
+
if (this.object !== void 0) {
|
|
276
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
277
|
+
return typeof field === "string" ? field : void 0;
|
|
278
|
+
}
|
|
279
|
+
const entry = this.#entries.get(key);
|
|
280
|
+
if (entry?.kind !== "string") return void 0;
|
|
281
|
+
if (entry.text === void 0 && entry.end >= 0 && entry.needsDecoding && entry.invalidAt === void 0) {
|
|
282
|
+
let text;
|
|
283
|
+
try {
|
|
284
|
+
text = JSON.parse(`"${this.slice(entry.start, entry.end)}"`);
|
|
285
|
+
} catch (_error) {}
|
|
286
|
+
if (text !== void 0) entry.text = {
|
|
287
|
+
at: entry.end,
|
|
288
|
+
length: text.length,
|
|
289
|
+
text
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
const read = entry.text ??= {
|
|
293
|
+
at: entry.start,
|
|
294
|
+
length: 0,
|
|
295
|
+
text: ""
|
|
296
|
+
};
|
|
297
|
+
this.readString(entry, read, Number.POSITIVE_INFINITY, true);
|
|
298
|
+
return read.text;
|
|
299
|
+
}
|
|
300
|
+
textPrefixNow(key, maxLength) {
|
|
301
|
+
if (this.object !== void 0) {
|
|
302
|
+
const field = Object.hasOwn(this.object, key) ? this.object[key] : void 0;
|
|
303
|
+
return typeof field === "string" ? field.slice(0, maxLength) : void 0;
|
|
304
|
+
}
|
|
305
|
+
const entry = this.#entries.get(key);
|
|
306
|
+
if (entry?.kind !== "string") return void 0;
|
|
307
|
+
const prefixes = entry.prefixes ??= /* @__PURE__ */ new Map();
|
|
308
|
+
let read = prefixes.get(maxLength);
|
|
309
|
+
if (read === void 0) {
|
|
310
|
+
read = {
|
|
311
|
+
at: entry.start,
|
|
312
|
+
length: 0,
|
|
313
|
+
text: ""
|
|
314
|
+
};
|
|
315
|
+
prefixes.set(maxLength, read);
|
|
316
|
+
}
|
|
317
|
+
this.readString(entry, read, maxLength, true);
|
|
318
|
+
return read.text;
|
|
319
|
+
}
|
|
320
|
+
valueNow(key) {
|
|
321
|
+
if (this.object !== void 0) {
|
|
322
|
+
if (!Object.hasOwn(this.object, key)) return void 0;
|
|
323
|
+
const field = this.object[key];
|
|
324
|
+
return typeof field === "string" ? void 0 : field;
|
|
325
|
+
}
|
|
326
|
+
const entry = this.#entries.get(key);
|
|
327
|
+
if (entry?.kind !== "value" || entry.end < 0 || entry.invalid) return void 0;
|
|
328
|
+
if (entry.parsed === void 0) try {
|
|
329
|
+
entry.parsed = JSON.parse(this.slice(entry.start, entry.end));
|
|
330
|
+
} catch (_error) {
|
|
331
|
+
entry.invalid = true;
|
|
332
|
+
this.#invalidValue = true;
|
|
333
|
+
}
|
|
334
|
+
return entry.parsed;
|
|
335
|
+
}
|
|
336
|
+
chunkAt(at) {
|
|
337
|
+
let low = 0;
|
|
338
|
+
let high = this.#ends.length;
|
|
339
|
+
while (low < high) {
|
|
340
|
+
const mid = low + high >>> 1;
|
|
341
|
+
if (this.#ends[mid] <= at) low = mid + 1;
|
|
342
|
+
else high = mid;
|
|
343
|
+
}
|
|
344
|
+
return low;
|
|
345
|
+
}
|
|
346
|
+
/** Materialize only a requested range, never the cumulative source. */
|
|
347
|
+
slice(start, end) {
|
|
348
|
+
if (start >= end) return "";
|
|
349
|
+
const first = this.chunkAt(start);
|
|
350
|
+
const last = this.chunkAt(end - 1);
|
|
351
|
+
const base = first === 0 ? 0 : this.#ends[first - 1];
|
|
352
|
+
if (first === last) return this.chunks[first].slice(start - base, end - base);
|
|
353
|
+
const parts = [this.chunks[first].slice(start - base)];
|
|
354
|
+
for (let i = first + 1; i < last; i++) parts.push(this.chunks[i]);
|
|
355
|
+
parts.push(this.chunks[last].slice(0, end - this.#ends[last - 1]));
|
|
356
|
+
return parts.join("");
|
|
357
|
+
}
|
|
358
|
+
readString(entry, read, limit, materialize) {
|
|
359
|
+
const end = Math.min(entry.end < 0 ? this.#consumed : entry.end, entry.invalidAt ?? Number.POSITIVE_INFINITY, this.#invalidAt ?? Number.POSITIVE_INFINITY);
|
|
360
|
+
if (!entry.needsDecoding) {
|
|
361
|
+
const length = Math.min(end - read.at, limit - read.length);
|
|
362
|
+
if (length <= 0) return;
|
|
363
|
+
if (materialize) read.text += this.slice(read.at, read.at + length);
|
|
364
|
+
read.at += length;
|
|
365
|
+
read.length += length;
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
let chunkIndex = this.chunkAt(read.at);
|
|
369
|
+
while (read.at < end && read.length < limit) {
|
|
370
|
+
const base = chunkIndex === 0 ? 0 : this.#ends[chunkIndex - 1];
|
|
371
|
+
const chunk = this.chunks[chunkIndex];
|
|
372
|
+
const remaining = chunk.slice(read.at - base, Math.min(chunk.length, end - base));
|
|
373
|
+
const boundary = remaining.search(CONTENT_ESCAPE);
|
|
374
|
+
const length = Math.min(boundary < 0 ? remaining.length : boundary, limit - read.length);
|
|
375
|
+
if (length > 0) {
|
|
376
|
+
if (materialize) read.text += remaining.slice(0, length);
|
|
377
|
+
read.at += length;
|
|
378
|
+
read.length += length;
|
|
379
|
+
if (read.at === base + chunk.length) chunkIndex++;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const type = remaining.length > 1 ? remaining[1] : read.at + 1 < end ? this.chunks[chunkIndex + 1][0] : void 0;
|
|
383
|
+
let decoded;
|
|
384
|
+
let width = 2;
|
|
385
|
+
if (remaining[0] === "\\" && type === void 0 && entry.end < 0) return;
|
|
386
|
+
if (remaining[0] === "\\" && type === "u") {
|
|
387
|
+
const hex = this.slice(read.at + 2, Math.min(end, read.at + 6));
|
|
388
|
+
let valid = true;
|
|
389
|
+
for (let i = 0; i < hex.length; i++) if (!isHex(hex[i])) valid = false;
|
|
390
|
+
if (valid) {
|
|
391
|
+
if (hex.length < 4 && entry.end < 0) return;
|
|
392
|
+
if (hex.length === 4) decoded = String.fromCharCode(Number.parseInt(hex, 16));
|
|
393
|
+
}
|
|
394
|
+
width = 6;
|
|
395
|
+
} else if (remaining[0] === "\\" && type !== void 0) decoded = SIMPLE_ESCAPES[type];
|
|
396
|
+
if (decoded === void 0) {
|
|
397
|
+
entry.invalidAt = read.at;
|
|
398
|
+
this.#invalidValue = true;
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (materialize) read.text += decoded;
|
|
402
|
+
read.length++;
|
|
403
|
+
read.at += width;
|
|
404
|
+
while (chunkIndex < this.chunks.length && read.at >= this.#ends[chunkIndex]) chunkIndex++;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
/** Locate new field ranges without decoding or parsing their contents. */
|
|
408
|
+
scan() {
|
|
409
|
+
if (this.object !== void 0 || this.#consumed === this.#size) return;
|
|
410
|
+
for (let i = this.chunkAt(this.#consumed); i < this.chunks.length && this.#invalidAt === void 0; i++) {
|
|
411
|
+
const pending = this.chunks[i];
|
|
412
|
+
const base = i === 0 ? 0 : this.#ends[i - 1];
|
|
413
|
+
for (let index = this.#consumed - base; index < pending.length && this.#mode !== "invalid"; index++) {
|
|
414
|
+
if (this.#mode === "string" || this.#mode === "nested" && this.#nestedInString) {
|
|
415
|
+
const end = this.stringBoundary(pending, index);
|
|
416
|
+
this.#consumed += end - index;
|
|
417
|
+
index = end;
|
|
418
|
+
if (index === pending.length) break;
|
|
419
|
+
}
|
|
420
|
+
this.step(pending[index], this.#consumed);
|
|
421
|
+
this.#consumed++;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
/** Only raw quotes and their preceding backslash runs can terminate a string. */
|
|
426
|
+
stringBoundary(fragment, start) {
|
|
427
|
+
let at = start;
|
|
428
|
+
while (true) {
|
|
429
|
+
const quote = fragment.indexOf("\"", at);
|
|
430
|
+
const end = quote < 0 ? fragment.length : quote;
|
|
431
|
+
if (this.#mode === "string") {
|
|
432
|
+
const entry = this.#current;
|
|
433
|
+
if (!entry.needsDecoding && CONTENT_ESCAPE.test(fragment.slice(at, end))) entry.needsDecoding = true;
|
|
434
|
+
}
|
|
435
|
+
let slashStart = end;
|
|
436
|
+
while (slashStart > at && fragment[slashStart - 1] === "\\") slashStart--;
|
|
437
|
+
const escaped = (end - slashStart) % 2 === 1 !== (slashStart === at && this.#escape);
|
|
438
|
+
this.#escape = quote < 0 && escaped;
|
|
439
|
+
if (quote < 0 || !escaped) return end;
|
|
440
|
+
at = quote + 1;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
step(c, at) {
|
|
444
|
+
switch (this.#mode) {
|
|
445
|
+
case "root":
|
|
446
|
+
if (isWhitespace(c)) return;
|
|
447
|
+
if (c === "{") {
|
|
448
|
+
this.#mode = "key-or-end";
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
this.fail();
|
|
452
|
+
return;
|
|
453
|
+
case "key-or-end":
|
|
454
|
+
if (isWhitespace(c)) return;
|
|
455
|
+
if (c === "}") {
|
|
456
|
+
this.#mode = "closed";
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (c === "\"") {
|
|
460
|
+
this.beginKey(at);
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
this.fail();
|
|
464
|
+
return;
|
|
465
|
+
case "key-only":
|
|
466
|
+
if (isWhitespace(c)) return;
|
|
467
|
+
if (c === "\"") {
|
|
468
|
+
this.beginKey(at);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
this.fail();
|
|
472
|
+
return;
|
|
473
|
+
case "key":
|
|
474
|
+
this.stepKey(c, at);
|
|
475
|
+
return;
|
|
476
|
+
case "colon":
|
|
477
|
+
if (isWhitespace(c)) return;
|
|
478
|
+
if (c === ":") {
|
|
479
|
+
this.#mode = "value";
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
this.fail();
|
|
483
|
+
return;
|
|
484
|
+
case "value":
|
|
485
|
+
this.beginValue(c, at);
|
|
486
|
+
return;
|
|
487
|
+
case "string": {
|
|
488
|
+
const entry = this.#current;
|
|
489
|
+
entry.end = at;
|
|
490
|
+
this.#current = null;
|
|
491
|
+
this.#mode = "comma-or-end";
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
case "scalar":
|
|
495
|
+
this.stepScalar(c, at);
|
|
496
|
+
return;
|
|
497
|
+
case "nested":
|
|
498
|
+
this.stepNested(c, at);
|
|
499
|
+
return;
|
|
500
|
+
case "comma-or-end":
|
|
501
|
+
if (isWhitespace(c)) return;
|
|
502
|
+
if (c === ",") {
|
|
503
|
+
this.#mode = "key-only";
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
if (c === "}") {
|
|
507
|
+
this.#mode = "closed";
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
this.fail();
|
|
511
|
+
return;
|
|
512
|
+
case "closed":
|
|
513
|
+
if (isWhitespace(c)) return;
|
|
514
|
+
this.fail();
|
|
515
|
+
return;
|
|
516
|
+
/* v8 ignore next 2 -- scan() stops stepping once the view is invalid. */
|
|
517
|
+
case "invalid": return;
|
|
518
|
+
/* v8 ignore next 2 -- Every scanner mode has a handler above. */
|
|
519
|
+
default: assertNever(this.#mode);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
fail() {
|
|
523
|
+
this.#invalidAt = this.#consumed;
|
|
524
|
+
this.#mode = "invalid";
|
|
525
|
+
this.#current = null;
|
|
526
|
+
}
|
|
527
|
+
beginKey(at) {
|
|
528
|
+
this.#mode = "key";
|
|
529
|
+
this.#keyStart = at + 1;
|
|
530
|
+
this.#keyEscaped = false;
|
|
531
|
+
this.#escape = false;
|
|
532
|
+
}
|
|
533
|
+
stepKey(c, at) {
|
|
534
|
+
if (c < " ") {
|
|
535
|
+
this.fail();
|
|
536
|
+
return;
|
|
537
|
+
}
|
|
538
|
+
if (this.#escape) {
|
|
539
|
+
this.#escape = false;
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (c === "\\") {
|
|
543
|
+
this.#escape = true;
|
|
544
|
+
this.#keyEscaped = true;
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (c !== "\"") return;
|
|
548
|
+
const raw = this.slice(this.#keyStart, at);
|
|
549
|
+
if (this.#keyEscaped) try {
|
|
550
|
+
this.#key = JSON.parse(`"${raw}"`);
|
|
551
|
+
} catch (_error) {
|
|
552
|
+
this.fail();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
else this.#key = raw;
|
|
556
|
+
this.#mode = "colon";
|
|
557
|
+
}
|
|
558
|
+
open(entry) {
|
|
559
|
+
if (!this.#entries.has(this.#key)) this.#order.push(this.#key);
|
|
560
|
+
this.#entries.set(this.#key, entry);
|
|
561
|
+
this.#current = entry;
|
|
562
|
+
}
|
|
563
|
+
beginValue(c, at) {
|
|
564
|
+
if (isWhitespace(c)) return;
|
|
565
|
+
if (c === "\"") {
|
|
566
|
+
this.open({
|
|
567
|
+
kind: "string",
|
|
568
|
+
start: at + 1,
|
|
569
|
+
end: -1,
|
|
570
|
+
needsDecoding: false,
|
|
571
|
+
invalidAt: void 0,
|
|
572
|
+
length: void 0,
|
|
573
|
+
text: void 0,
|
|
574
|
+
prefixes: void 0
|
|
575
|
+
});
|
|
576
|
+
this.#escape = false;
|
|
577
|
+
this.#mode = "string";
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
if (c === "}" || c === "," || c === ":" || c === "]") {
|
|
581
|
+
this.fail();
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
this.open({
|
|
585
|
+
kind: "value",
|
|
586
|
+
start: at,
|
|
587
|
+
end: -1,
|
|
588
|
+
parsed: void 0,
|
|
589
|
+
invalid: false
|
|
590
|
+
});
|
|
591
|
+
if (c === "{" || c === "[") {
|
|
592
|
+
this.#mode = "nested";
|
|
593
|
+
this.#nestedEnds = [c === "{" ? "}" : "]"];
|
|
594
|
+
this.#nestedInString = false;
|
|
595
|
+
this.#escape = false;
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
this.#mode = "scalar";
|
|
599
|
+
}
|
|
600
|
+
stepScalar(c, at) {
|
|
601
|
+
if (c !== "," && c !== "}" && !isWhitespace(c)) return;
|
|
602
|
+
this.closeValue(at);
|
|
603
|
+
this.#mode = c === "," ? "key-only" : c === "}" ? "closed" : "comma-or-end";
|
|
604
|
+
}
|
|
605
|
+
stepNested(c, at) {
|
|
606
|
+
if (this.#nestedInString) {
|
|
607
|
+
this.#nestedInString = false;
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (c === "\"") {
|
|
611
|
+
this.#nestedInString = true;
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (c === "{" || c === "[") {
|
|
615
|
+
this.#nestedEnds.push(c === "{" ? "}" : "]");
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
if (c === "}" || c === "]") {
|
|
619
|
+
if (this.#nestedEnds.pop() !== c) {
|
|
620
|
+
this.fail();
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
if (this.#nestedEnds.length === 0) {
|
|
624
|
+
this.closeValue(at + 1);
|
|
625
|
+
this.#mode = "comma-or-end";
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
closeValue(end) {
|
|
630
|
+
const entry = this.#current;
|
|
631
|
+
entry.end = end;
|
|
632
|
+
this.#current = null;
|
|
633
|
+
}
|
|
634
|
+
});
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region ../../util/values/src/index.ts
|
|
637
|
+
/**
|
|
638
|
+
* Mark an unreachable closed-union branch.
|
|
639
|
+
* @param value - impossible value; an unhandled typed variant fails at the call site.
|
|
640
|
+
* @param context - optional switch-site label included in the failure message.
|
|
641
|
+
* @returns never; a runtime value that escaped its type always throws.
|
|
642
|
+
*/
|
|
643
|
+
function assertNever(value, context) {
|
|
644
|
+
const rendered = JSON.stringify(value) ?? String(value);
|
|
645
|
+
throw new Error(`unreachable variant${context ? ` in ${context}` : ""}: ${rendered}`);
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Weak-key lookup with a strongly retained iterable set of associated values.
|
|
649
|
+
*
|
|
650
|
+
* Each value must belong to only one key. The container performs no automatic
|
|
651
|
+
* cleanup; owners delete associations or clear the container at lifecycle end.
|
|
652
|
+
*/
|
|
653
|
+
var WeakMapWithValues = class {
|
|
654
|
+
keys = /* @__PURE__ */ new WeakMap();
|
|
655
|
+
valueSet = /* @__PURE__ */ new Set();
|
|
656
|
+
/** Live strongly retained values in insertion order. */
|
|
657
|
+
values = this.valueSet;
|
|
658
|
+
/**
|
|
659
|
+
* Read the value associated with a key.
|
|
660
|
+
* @param key - weakly held lookup key.
|
|
661
|
+
* @returns the associated value, or absence.
|
|
662
|
+
*/
|
|
663
|
+
get(key) {
|
|
664
|
+
return this.keys.get(key);
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Test whether a key has an association.
|
|
668
|
+
* @param key - weakly held lookup key.
|
|
669
|
+
* @returns whether the key is present.
|
|
670
|
+
*/
|
|
671
|
+
has(key) {
|
|
672
|
+
return this.keys.has(key);
|
|
673
|
+
}
|
|
674
|
+
/**
|
|
675
|
+
* Associate one key with one caller-unique value.
|
|
676
|
+
* @param key - weakly held lookup key.
|
|
677
|
+
* @param value - strongly retained value that belongs to no other key.
|
|
678
|
+
* @returns this container.
|
|
679
|
+
*/
|
|
680
|
+
set(key, value) {
|
|
681
|
+
if (this.keys.has(key)) {
|
|
682
|
+
const previous = this.keys.get(key);
|
|
683
|
+
if (previous === value) return this;
|
|
684
|
+
this.valueSet.delete(previous);
|
|
685
|
+
}
|
|
686
|
+
this.keys.set(key, value);
|
|
687
|
+
this.valueSet.add(value);
|
|
688
|
+
return this;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Remove one association and its strongly retained value.
|
|
692
|
+
* @param key - weakly held lookup key.
|
|
693
|
+
* @returns whether an association was removed.
|
|
694
|
+
*/
|
|
695
|
+
delete(key) {
|
|
696
|
+
if (!this.keys.has(key)) return false;
|
|
697
|
+
const value = this.keys.get(key);
|
|
698
|
+
const deleted = this.keys.delete(key);
|
|
699
|
+
this.valueSet.delete(value);
|
|
700
|
+
return deleted;
|
|
701
|
+
}
|
|
702
|
+
/** Remove every association and strongly retained value. */
|
|
703
|
+
clear() {
|
|
704
|
+
this.keys = /* @__PURE__ */ new WeakMap();
|
|
705
|
+
this.valueSet.clear();
|
|
706
|
+
}
|
|
707
|
+
};
|
|
708
|
+
//#endregion
|
|
709
|
+
//#region ../../context/file-reference/src/grammar.ts
|
|
710
|
+
/**
|
|
711
|
+
* Extract an `@path` or `@"path with spaces` token at the cursor. An `@`
|
|
712
|
+
* inside another token, such as an email address, is not a completion trigger.
|
|
713
|
+
* @param line - current editor line.
|
|
714
|
+
* @param cursorCol - cursor column within that line.
|
|
715
|
+
* @returns the active token, or `undefined` outside an `@` token.
|
|
716
|
+
*/
|
|
717
|
+
function activeAtToken(line, cursorCol) {
|
|
718
|
+
const beforeCursor = line.slice(0, cursorCol);
|
|
719
|
+
const quoted = /(?:^|\s)(@"([^"]*))$/u.exec(beforeCursor);
|
|
720
|
+
if (quoted?.[1] !== void 0 && quoted[2] !== void 0) return {
|
|
721
|
+
prefix: quoted[1],
|
|
722
|
+
query: quoted[2],
|
|
723
|
+
quoted: true
|
|
724
|
+
};
|
|
725
|
+
const plain = /(?:^|\s)(@([^\s]*))$/u.exec(beforeCursor);
|
|
726
|
+
if (plain?.[1] === void 0 || plain[2] === void 0) return void 0;
|
|
727
|
+
return {
|
|
728
|
+
prefix: plain[1],
|
|
729
|
+
query: plain[2],
|
|
730
|
+
quoted: false
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
//#endregion
|
|
734
|
+
//#region lib/types/core/detect.js
|
|
735
|
+
/**
|
|
736
|
+
* Trigger detection pure core. Scans backward from
|
|
737
|
+
* the caret for a live trigger char under the guard tier and applies the
|
|
738
|
+
* word-boundary rules. Zero React / DOM / cordis.
|
|
739
|
+
*/
|
|
740
|
+
const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
|
741
|
+
const WHITESPACE = /\s/u;
|
|
742
|
+
/**
|
|
743
|
+
* Word-boundary rule: a trigger char opens only at start-of-draft, after
|
|
744
|
+
* whitespace (newlines included), or after punctuation. Two URL carve-outs
|
|
745
|
+
* keep '/' dead inside URLs (both pinned by tests): '/' after a ':' that
|
|
746
|
+
* itself follows a non-whitespace char (scheme separator, `https:/…`), and
|
|
747
|
+
* '/' directly after another '/' (second slash of `//`).
|
|
748
|
+
*/
|
|
749
|
+
function boundaryOk(draft, index, char) {
|
|
750
|
+
if (index === 0) return true;
|
|
751
|
+
const prev = draft.charAt(index - 1);
|
|
752
|
+
if (WHITESPACE.test(prev)) return true;
|
|
753
|
+
if (WORD_CHAR.test(prev)) return false;
|
|
754
|
+
if (char === "/") {
|
|
755
|
+
if (prev === "/") return false;
|
|
756
|
+
if (prev === ":" && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false;
|
|
757
|
+
}
|
|
758
|
+
return true;
|
|
759
|
+
}
|
|
760
|
+
/**
|
|
761
|
+
* Detect a trigger token at the caret. `@` first uses the shared grammar,
|
|
762
|
+
* including an open quoted token that may span whitespace. Slash detection
|
|
763
|
+
* scans left to the first whitespace; slashes failing the word boundary are
|
|
764
|
+
* treated as ordinary token chars and the scan continues (URL slashes).
|
|
765
|
+
* Guard tiers: plain = both chars live; claimed = '/' fully suppressed,
|
|
766
|
+
* '@' live; frozen = none.
|
|
767
|
+
*
|
|
768
|
+
* @param draft - Full draft text.
|
|
769
|
+
* @param caret - Caret offset into `draft`.
|
|
770
|
+
* @param guard - Availability tier derived from the input phase.
|
|
771
|
+
* @returns The hit with `query` = trigger-to-caret slice and `span` =
|
|
772
|
+
* `{start: triggerIndex, end: caret}`; `span.draftRev` is a placeholder `0`
|
|
773
|
+
* — the calling shell stamps the real revision. Null when no trigger is
|
|
774
|
+
* live at the caret.
|
|
775
|
+
*/
|
|
776
|
+
const detectTrigger = (draft, caret, guard) => {
|
|
777
|
+
if (guard.tier === "frozen") return null;
|
|
778
|
+
const at = activeAtToken(draft, caret);
|
|
779
|
+
if (at !== void 0) {
|
|
780
|
+
const start = caret - at.prefix.length;
|
|
781
|
+
return {
|
|
782
|
+
trigger: "@",
|
|
783
|
+
query: at.query,
|
|
784
|
+
quoted: at.quoted,
|
|
785
|
+
position: draft.search(/\S/) === start ? "leading" : "inline",
|
|
786
|
+
span: {
|
|
787
|
+
start,
|
|
788
|
+
end: caret,
|
|
789
|
+
draftRev: 0
|
|
790
|
+
}
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
for (let i = caret - 1; i >= 0; i--) {
|
|
794
|
+
const ch = draft.charAt(i);
|
|
795
|
+
if (WHITESPACE.test(ch)) return null;
|
|
796
|
+
if (ch !== "/") continue;
|
|
797
|
+
if (guard.tier === "claimed") continue;
|
|
798
|
+
if (!boundaryOk(draft, i, ch)) continue;
|
|
799
|
+
return {
|
|
800
|
+
trigger: ch,
|
|
801
|
+
query: draft.slice(i + 1, caret),
|
|
802
|
+
quoted: false,
|
|
803
|
+
position: draft.search(/\S/) === i ? "leading" : "inline",
|
|
804
|
+
span: {
|
|
805
|
+
start: i,
|
|
806
|
+
end: caret,
|
|
807
|
+
draftRev: 0
|
|
808
|
+
}
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
return null;
|
|
812
|
+
};
|
|
813
|
+
//#endregion
|
|
814
|
+
//#region lib/types/core/menu.js
|
|
815
|
+
/** Closed rest state with generation 0; store initializer and test seed. */
|
|
816
|
+
const MENU_CLOSED = {
|
|
817
|
+
open: false,
|
|
818
|
+
hit: null,
|
|
819
|
+
generation: 0,
|
|
820
|
+
groups: [],
|
|
821
|
+
highlight: null
|
|
822
|
+
};
|
|
823
|
+
/**
|
|
824
|
+
* Replace the group roster with pending groups for `sources`, in order.
|
|
825
|
+
* Shell-side step before dispatching `hit` on a fresh menu open.
|
|
826
|
+
*
|
|
827
|
+
* @param state - Current menu state.
|
|
828
|
+
* @param sources - Sources registered for the hit trigger, in menu order.
|
|
829
|
+
* @returns State carrying the new pending roster; highlight cleared.
|
|
830
|
+
*/
|
|
831
|
+
function seedGroups(state, sources) {
|
|
832
|
+
return {
|
|
833
|
+
...state,
|
|
834
|
+
groups: sources.map((source) => ({
|
|
835
|
+
source: source.name,
|
|
836
|
+
...source.showGroupTitle === false ? { showGroupTitle: false } : {},
|
|
837
|
+
status: "pending",
|
|
838
|
+
items: []
|
|
839
|
+
})),
|
|
840
|
+
highlight: null
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
/** Close, preserving the generation so in-flight settlements stay droppable. */
|
|
844
|
+
const closed = (state) => state.open || state.hit !== null || state.groups.length > 0 || state.highlight !== null ? {
|
|
845
|
+
open: false,
|
|
846
|
+
hit: null,
|
|
847
|
+
generation: state.generation,
|
|
848
|
+
groups: [],
|
|
849
|
+
highlight: null
|
|
850
|
+
} : state;
|
|
851
|
+
/** First item of the first non-empty ready group, or null. */
|
|
852
|
+
function firstHighlight(groups) {
|
|
853
|
+
for (const g of groups) if (g.status === "ready" && g.items.length > 0) return {
|
|
854
|
+
source: g.source,
|
|
855
|
+
index: 0
|
|
856
|
+
};
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
/** The highlight itself when it still points at a ready item, else null. */
|
|
860
|
+
function validHighlight(highlight, groups) {
|
|
861
|
+
if (!highlight) return null;
|
|
862
|
+
const g = groups.find((x) => x.source === highlight.source);
|
|
863
|
+
return g && g.status === "ready" && highlight.index < g.items.length ? highlight : null;
|
|
864
|
+
}
|
|
865
|
+
/** Flatten ready items into (source, index) positions in group order. */
|
|
866
|
+
function positions(groups) {
|
|
867
|
+
const out = [];
|
|
868
|
+
for (const g of groups) {
|
|
869
|
+
if (g.status !== "ready") continue;
|
|
870
|
+
for (let i = 0; i < g.items.length; i++) out.push({
|
|
871
|
+
source: g.source,
|
|
872
|
+
index: i
|
|
873
|
+
});
|
|
874
|
+
}
|
|
875
|
+
return out;
|
|
876
|
+
}
|
|
877
|
+
/** True when every group is ready with zero items (the auto-close condition). */
|
|
878
|
+
const allReadyEmpty = (groups) => groups.every((g) => g.status === "ready" && g.items.length === 0);
|
|
879
|
+
/**
|
|
880
|
+
* Pure menu reducer. `hit` opens a new generation over the seeded roster
|
|
881
|
+
* (null hit closes); `source-settled` outside the current generation, the
|
|
882
|
+
* open menu, or the roster is dropped; a settlement or failure leaving every
|
|
883
|
+
* group ready-and-empty (or no groups) auto-closes; `source-failed` silently
|
|
884
|
+
* removes the group (the shell logs); `move` cycles the highlight across
|
|
885
|
+
* ready items; `hover` parks it on one ready item (pointer and keyboard
|
|
886
|
+
* share the single highlight — last input wins).
|
|
887
|
+
*
|
|
888
|
+
* @param state - Current menu state.
|
|
889
|
+
* @param ev - Menu event.
|
|
890
|
+
* @returns Next state; the same reference when stale or a no-op.
|
|
891
|
+
*/
|
|
892
|
+
const menuReduce = (state, ev) => {
|
|
893
|
+
switch (ev.type) {
|
|
894
|
+
case "hit":
|
|
895
|
+
if (ev.hit === null) return closed(state);
|
|
896
|
+
return {
|
|
897
|
+
open: true,
|
|
898
|
+
hit: ev.hit,
|
|
899
|
+
generation: state.generation + 1,
|
|
900
|
+
groups: state.groups.map((g) => ({
|
|
901
|
+
...g,
|
|
902
|
+
status: "pending"
|
|
903
|
+
})),
|
|
904
|
+
highlight: state.highlight
|
|
905
|
+
};
|
|
906
|
+
case "source-settled": {
|
|
907
|
+
if (!state.open || ev.generation !== state.generation) return state;
|
|
908
|
+
const idx = state.groups.findIndex((g) => g.source === ev.source);
|
|
909
|
+
if (idx < 0) return state;
|
|
910
|
+
const items = ev.items ?? [];
|
|
911
|
+
const groups = state.groups.map((g, i) => i === idx ? {
|
|
912
|
+
...g,
|
|
913
|
+
status: "ready",
|
|
914
|
+
items
|
|
915
|
+
} : g);
|
|
916
|
+
if (allReadyEmpty(groups)) return closed(state);
|
|
917
|
+
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups);
|
|
918
|
+
return {
|
|
919
|
+
...state,
|
|
920
|
+
groups,
|
|
921
|
+
highlight
|
|
922
|
+
};
|
|
923
|
+
}
|
|
924
|
+
case "source-failed": {
|
|
925
|
+
if (!state.open || ev.generation !== state.generation) return state;
|
|
926
|
+
if (!state.groups.some((g) => g.source === ev.source)) return state;
|
|
927
|
+
const groups = state.groups.filter((g) => g.source !== ev.source);
|
|
928
|
+
if (groups.length === 0 || allReadyEmpty(groups)) return closed(state);
|
|
929
|
+
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups);
|
|
930
|
+
return {
|
|
931
|
+
...state,
|
|
932
|
+
groups,
|
|
933
|
+
highlight
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
case "move": {
|
|
937
|
+
if (!state.open) return state;
|
|
938
|
+
const pos = positions(state.groups);
|
|
939
|
+
if (pos.length === 0) return state;
|
|
940
|
+
const hl = state.highlight;
|
|
941
|
+
const at = hl ? pos.findIndex((p) => p.source === hl.source && p.index === hl.index) : -1;
|
|
942
|
+
const next = pos[at < 0 ? ev.dir === 1 ? 0 : pos.length - 1 : (at + ev.dir + pos.length) % pos.length];
|
|
943
|
+
if (next === void 0) return state;
|
|
944
|
+
if (hl && next.source === hl.source && next.index === hl.index) return state;
|
|
945
|
+
return {
|
|
946
|
+
...state,
|
|
947
|
+
highlight: next
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
case "hover": {
|
|
951
|
+
if (!state.open) return state;
|
|
952
|
+
const target = validHighlight({
|
|
953
|
+
source: ev.source,
|
|
954
|
+
index: ev.index
|
|
955
|
+
}, state.groups);
|
|
956
|
+
if (target === null) return state;
|
|
957
|
+
const hl = state.highlight;
|
|
958
|
+
if (hl && hl.source === target.source && hl.index === target.index) return state;
|
|
959
|
+
return {
|
|
960
|
+
...state,
|
|
961
|
+
highlight: target
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
case "close": return closed(state);
|
|
965
|
+
}
|
|
966
|
+
};
|
|
967
|
+
//#endregion
|
|
968
|
+
//#region lib/types/client/controller.js
|
|
969
|
+
/** Whether a tracked hit is the one the user just dismissed (same token, same query). */
|
|
970
|
+
function dismissedHit(dismissed, hit) {
|
|
971
|
+
return dismissed.trigger === hit.trigger && dismissed.query === hit.query && dismissed.quoted === hit.quoted && dismissed.start === hit.span.start && dismissed.end === hit.span.end;
|
|
972
|
+
}
|
|
973
|
+
/**
|
|
974
|
+
* Per-session trigger pipeline state and orchestration. All mutation stays
|
|
975
|
+
* inside; MenuView renders from {@link InputTriggerController.menu} and routes
|
|
976
|
+
* pointer picks back through {@link InputTriggerController.pick}.
|
|
977
|
+
*/
|
|
978
|
+
var InputTriggerController = class {
|
|
979
|
+
deps;
|
|
980
|
+
/** Menu state store (per-session; survives session switches, dies with the scope). */
|
|
981
|
+
menu = (0, _qilin_agent_client_store.createSnapshotStore)(MENU_CLOSED);
|
|
982
|
+
/**
|
|
983
|
+
* Name of the source opened through the programmatic launcher, or null for
|
|
984
|
+
* trigger-detected/closed menus. Composer chrome subscribes to this store
|
|
985
|
+
* for the launcher's expanded state without owning a second menu model.
|
|
986
|
+
*/
|
|
987
|
+
launcher = (0, _qilin_agent_client_store.createSnapshotStore)(null);
|
|
988
|
+
/**
|
|
989
|
+
* Crumbs published by each header-bearing source for the open menu, keyed
|
|
990
|
+
* by source name. A snapshot store like {@link InputTriggerController.launcher}:
|
|
991
|
+
* the answer changes with every hit, and render-side consumers subscribe
|
|
992
|
+
* instead of re-polling sources during a render.
|
|
993
|
+
*/
|
|
994
|
+
headers = (0, _qilin_agent_client_store.createSnapshotStore)(/* @__PURE__ */ new Map());
|
|
995
|
+
/**
|
|
996
|
+
* Aggregated hot reference lexicon, grouped by trigger (plain-text-reference decision;
|
|
997
|
+
* see .agents/notes/archived/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md):
|
|
998
|
+
* sources implementing the lexicon hook are polled with the session
|
|
999
|
+
* projection; undefined answers (roll not hot yet) are skipped; multiple
|
|
1000
|
+
* sources on one trigger concatenate in registration order. A snapshot
|
|
1001
|
+
* store because rolls change asynchronously (catalog settles, children
|
|
1002
|
+
* spawn/exit) — render-side consumers subscribe instead of re-reading a
|
|
1003
|
+
* mutable answer.
|
|
1004
|
+
*/
|
|
1005
|
+
lexicon = (0, _qilin_agent_client_store.createSnapshotStore)(/* @__PURE__ */ new Map());
|
|
1006
|
+
/** The authoritative hit: single truth for span CAS material (menu snapshot never carries it alone). */
|
|
1007
|
+
hit = null;
|
|
1008
|
+
/**
|
|
1009
|
+
* Identity of the hit whose menu the user dismissed. A dismissal means "not
|
|
1010
|
+
* this one, not now": the same token with the same query keeps its menu
|
|
1011
|
+
* closed, so restoring the caret after a dismissal cannot reopen it. Typing
|
|
1012
|
+
* (a new query) or moving to another token clears it.
|
|
1013
|
+
*/
|
|
1014
|
+
dismissed = null;
|
|
1015
|
+
/** Whether the open menu was reached by a drill pick; cleared with the menu. */
|
|
1016
|
+
drilled = false;
|
|
1017
|
+
fetch = null;
|
|
1018
|
+
disposed = false;
|
|
1019
|
+
/** Per-source lexicon unsubscribers (sources without the hook never enter). */
|
|
1020
|
+
lexiconOffs = /* @__PURE__ */ new Map();
|
|
1021
|
+
constructor(deps) {
|
|
1022
|
+
this.deps = deps;
|
|
1023
|
+
const projection = this.project();
|
|
1024
|
+
for (const src of deps.roster.all()) {
|
|
1025
|
+
src.warm?.(projection);
|
|
1026
|
+
this.watchLexicon(src, projection);
|
|
1027
|
+
}
|
|
1028
|
+
this.refreshLexicon();
|
|
1029
|
+
}
|
|
1030
|
+
/**
|
|
1031
|
+
* Feed a draft/caret change through trigger detection and drive the menu.
|
|
1032
|
+
* @param draft - full draft text.
|
|
1033
|
+
* @param caret - caret offset into `draft`.
|
|
1034
|
+
* @param guard - availability tier derived from the input phase.
|
|
1035
|
+
* @param draftRev - the input machine's current draft revision, stamped
|
|
1036
|
+
* into the hit span for pick-time CAS.
|
|
1037
|
+
*/
|
|
1038
|
+
track(draft, caret, guard, draftRev) {
|
|
1039
|
+
if (this.disposed) return;
|
|
1040
|
+
const launched = this.launcher.getSnapshot() !== null;
|
|
1041
|
+
this.clearLauncher();
|
|
1042
|
+
const raw = detectTrigger(draft, caret, guard);
|
|
1043
|
+
if (raw === null) {
|
|
1044
|
+
if (launched) return;
|
|
1045
|
+
this.hit = null;
|
|
1046
|
+
if (guard.tier !== "frozen") this.dismissed = null;
|
|
1047
|
+
this.stopFetch();
|
|
1048
|
+
this.reduce({ type: "close" });
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const hit = {
|
|
1052
|
+
...raw,
|
|
1053
|
+
span: {
|
|
1054
|
+
...raw.span,
|
|
1055
|
+
draftRev
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
if (launched) this.dismissed = null;
|
|
1059
|
+
if (this.dismissed !== null) if (!dismissedHit(this.dismissed, hit)) this.dismissed = null;
|
|
1060
|
+
else {
|
|
1061
|
+
this.hit = hit;
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const prev = this.menu.getSnapshot();
|
|
1065
|
+
const same = !launched && prev.open && prev.hit !== null && prev.hit.trigger === hit.trigger && prev.hit.query === hit.query && prev.hit.quoted === hit.quoted && prev.hit.span.start === hit.span.start && prev.hit.span.end === hit.span.end;
|
|
1066
|
+
this.hit = hit;
|
|
1067
|
+
if (same) return;
|
|
1068
|
+
const roster = this.deps.roster.sources(hit.trigger);
|
|
1069
|
+
if (roster.length === 0) {
|
|
1070
|
+
this.stopFetch();
|
|
1071
|
+
this.reduce({ type: "close" });
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
if (launched || !prev.open || prev.hit === null || prev.hit.trigger !== hit.trigger) this.menu.set(seedGroups(this.menu.getSnapshot(), roster));
|
|
1075
|
+
this.reduce({
|
|
1076
|
+
type: "hit",
|
|
1077
|
+
hit
|
|
1078
|
+
});
|
|
1079
|
+
this.refreshHeaders(hit, roster);
|
|
1080
|
+
this.fetchCandidates(hit, roster);
|
|
1081
|
+
}
|
|
1082
|
+
/**
|
|
1083
|
+
* Toggle a menu containing exactly one registered source. The supplied hit
|
|
1084
|
+
* is a synthetic selection span rather than a typed trigger token, but
|
|
1085
|
+
* picks deliberately reuse the ordinary source callback and scoped input
|
|
1086
|
+
* mutation pipeline.
|
|
1087
|
+
* @param source - registered source name under `hit.trigger`.
|
|
1088
|
+
* @param hit - synthetic hit carrying position and pick-time draft CAS.
|
|
1089
|
+
*/
|
|
1090
|
+
toggleSource(source, hit) {
|
|
1091
|
+
if (this.disposed) return;
|
|
1092
|
+
if (this.launcher.getSnapshot() === source && this.menu.getSnapshot().open) {
|
|
1093
|
+
this.dismiss();
|
|
1094
|
+
return;
|
|
1095
|
+
}
|
|
1096
|
+
const match = this.deps.roster.sources(hit.trigger).find((item) => item.name === source);
|
|
1097
|
+
if (match === void 0) {
|
|
1098
|
+
this.dismiss();
|
|
1099
|
+
return;
|
|
1100
|
+
}
|
|
1101
|
+
this.stopFetch();
|
|
1102
|
+
this.hit = hit;
|
|
1103
|
+
this.launcher.set(source);
|
|
1104
|
+
this.menu.set(seedGroups(this.menu.getSnapshot(), [match]));
|
|
1105
|
+
this.reduce({
|
|
1106
|
+
type: "hit",
|
|
1107
|
+
hit
|
|
1108
|
+
});
|
|
1109
|
+
this.refreshHeaders(hit, [match]);
|
|
1110
|
+
this.fetchCandidates(hit, [match]);
|
|
1111
|
+
}
|
|
1112
|
+
/**
|
|
1113
|
+
* Pointer pick from MenuView: route the clicked candidate through onPick
|
|
1114
|
+
* and execute claim/insert outcomes via the scoped input events.
|
|
1115
|
+
* @param source - source (group) name.
|
|
1116
|
+
* @param index - candidate index within the group.
|
|
1117
|
+
* @param action - settling pick (default) or the candidate's drill action.
|
|
1118
|
+
*/
|
|
1119
|
+
pick(source, index, action = "pick") {
|
|
1120
|
+
const state = this.menu.getSnapshot();
|
|
1121
|
+
const hit = this.hit;
|
|
1122
|
+
if (this.disposed || !state.open || hit === null) return;
|
|
1123
|
+
const group = state.groups.find((g) => g.source === source);
|
|
1124
|
+
const candidate = group !== void 0 && group.status === "ready" ? group.items[index] : void 0;
|
|
1125
|
+
if (candidate === void 0) return;
|
|
1126
|
+
const src = this.deps.roster.sources(hit.trigger).find((s) => s.name === source);
|
|
1127
|
+
if (src === void 0) return;
|
|
1128
|
+
this.settle(src, candidate, hit, action);
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Pointer pick on one crumb of a source's menu header: route it through the
|
|
1132
|
+
* same drill path a folder row takes, so returning to a step and descending
|
|
1133
|
+
* into one share one outcome.
|
|
1134
|
+
* @param source - source (group) name.
|
|
1135
|
+
* @param index - crumb index within that source's published header.
|
|
1136
|
+
*/
|
|
1137
|
+
pickCrumb(source, index) {
|
|
1138
|
+
const hit = this.hit;
|
|
1139
|
+
if (this.disposed || !this.menu.getSnapshot().open || hit === null) return;
|
|
1140
|
+
const crumb = this.headers.getSnapshot().get(source)?.[index];
|
|
1141
|
+
if (crumb === void 0 || crumb.current === true) return;
|
|
1142
|
+
const src = this.deps.roster.sources(hit.trigger).find((s) => s.name === source);
|
|
1143
|
+
if (src === void 0) return;
|
|
1144
|
+
this.settle(src, {
|
|
1145
|
+
name: crumb.label,
|
|
1146
|
+
value: crumb.value
|
|
1147
|
+
}, hit, "drill");
|
|
1148
|
+
}
|
|
1149
|
+
/**
|
|
1150
|
+
* Pointer hover from MenuView: park the shared highlight on the hovered
|
|
1151
|
+
* candidate (keyboard `move` and pointer hover drive one highlight —
|
|
1152
|
+
* last input wins).
|
|
1153
|
+
* @param source - source (group) name.
|
|
1154
|
+
* @param index - candidate index within the group.
|
|
1155
|
+
*/
|
|
1156
|
+
hover(source, index) {
|
|
1157
|
+
if (this.disposed) return;
|
|
1158
|
+
this.reduce({
|
|
1159
|
+
type: "hover",
|
|
1160
|
+
source,
|
|
1161
|
+
index
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Keyboard arbitration while the menu is open.
|
|
1166
|
+
* @param key - intercepted key.
|
|
1167
|
+
* @param composing - inside IME composition: everything passes.
|
|
1168
|
+
* @returns `pass` when the browser keeps the key (closed menu, no
|
|
1169
|
+
* highlight, or a vanished candidate), `consumed` when the menu handled
|
|
1170
|
+
* the key without a settling pick (move, close, drill descent, or a
|
|
1171
|
+
* pending-refinement no-op), or `pick-highlighted` when the highlighted
|
|
1172
|
+
* candidate settled and the menu closed.
|
|
1173
|
+
*/
|
|
1174
|
+
arbitrate(key, composing) {
|
|
1175
|
+
if (composing || this.disposed) return "pass";
|
|
1176
|
+
const state = this.menu.getSnapshot();
|
|
1177
|
+
if (!state.open) return "pass";
|
|
1178
|
+
switch (key) {
|
|
1179
|
+
case "up":
|
|
1180
|
+
this.reduce({
|
|
1181
|
+
type: "move",
|
|
1182
|
+
dir: -1
|
|
1183
|
+
});
|
|
1184
|
+
return "consumed";
|
|
1185
|
+
case "down":
|
|
1186
|
+
this.reduce({
|
|
1187
|
+
type: "move",
|
|
1188
|
+
dir: 1
|
|
1189
|
+
});
|
|
1190
|
+
return "consumed";
|
|
1191
|
+
case "escape":
|
|
1192
|
+
case "tabBack":
|
|
1193
|
+
this.rememberDismissed();
|
|
1194
|
+
this.stopFetch();
|
|
1195
|
+
this.reduce({ type: "close" });
|
|
1196
|
+
return "consumed";
|
|
1197
|
+
case "enter": {
|
|
1198
|
+
if (state.highlight === null) return "pass";
|
|
1199
|
+
const group = state.groups.find((g) => g.source === state.highlight?.source);
|
|
1200
|
+
if (group === void 0 || group.status !== "ready") return "consumed";
|
|
1201
|
+
this.pick(state.highlight.source, state.highlight.index);
|
|
1202
|
+
return "pick-highlighted";
|
|
1203
|
+
}
|
|
1204
|
+
case "tab": {
|
|
1205
|
+
if (state.highlight === null) return "pass";
|
|
1206
|
+
const group = state.groups.find((g) => g.source === state.highlight?.source);
|
|
1207
|
+
if (group === void 0 || group.status !== "ready") return "consumed";
|
|
1208
|
+
const item = group.items[state.highlight.index];
|
|
1209
|
+
if (item === void 0) return "pass";
|
|
1210
|
+
if (item.drill === true) {
|
|
1211
|
+
this.pick(state.highlight.source, state.highlight.index, "drill");
|
|
1212
|
+
return "consumed";
|
|
1213
|
+
}
|
|
1214
|
+
this.pick(state.highlight.source, state.highlight.index);
|
|
1215
|
+
return "pick-highlighted";
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* Space adjudication over the just-completed leading token: polls sources'
|
|
1221
|
+
* matchSpace (hot state, synchronous) and dispatches the outcome itself.
|
|
1222
|
+
* @returns true when a claim/insert was actually applied by the input —
|
|
1223
|
+
* the caller preventDefaults exactly then.
|
|
1224
|
+
*/
|
|
1225
|
+
onSpace() {
|
|
1226
|
+
const hit = this.hit;
|
|
1227
|
+
if (this.disposed || hit === null || hit.position !== "leading") return false;
|
|
1228
|
+
const token = hit.trigger + hit.query;
|
|
1229
|
+
const projection = this.project();
|
|
1230
|
+
for (const src of this.deps.roster.sources(hit.trigger)) {
|
|
1231
|
+
if (src.matchSpace === void 0) continue;
|
|
1232
|
+
const outcome = src.matchSpace(projection, token);
|
|
1233
|
+
if (outcome === void 0) continue;
|
|
1234
|
+
if (outcome === "handled") return true;
|
|
1235
|
+
return this.execute(outcome, hit.span);
|
|
1236
|
+
}
|
|
1237
|
+
return false;
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Serialize one reference occurrence to its model form via the owning
|
|
1241
|
+
* source's codec (prompt serialization: registry → explicit
|
|
1242
|
+
* call → await). Owner missing or codec-less rejects — the submit attempt
|
|
1243
|
+
* blocks instead of silently downgrading to the clipboard text.
|
|
1244
|
+
* @param source - owning source name.
|
|
1245
|
+
* @param ref - owner-scoped reference id.
|
|
1246
|
+
* @param signal - the submit attempt's abort signal.
|
|
1247
|
+
* @returns the model representation (e.g. `<skill>name</skill>`).
|
|
1248
|
+
*/
|
|
1249
|
+
serializeReference(source, ref, signal) {
|
|
1250
|
+
const owner = this.deps.roster.all().find((s) => s.name === source);
|
|
1251
|
+
if (owner?.codec === void 0) return Promise.reject(/* @__PURE__ */ new Error(`slash: no serializer for reference source "${source}"`));
|
|
1252
|
+
return owner.codec.serialize(ref, signal);
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Route a chip to its owner or an editable token to its current lexicon owner.
|
|
1256
|
+
* @param source - chip source name; undefined for editable text.
|
|
1257
|
+
* @param reference - source-owned id and optional chip glyph.
|
|
1258
|
+
* @returns whether an owner accepted the preview, possibly awaiting its catalog.
|
|
1259
|
+
*/
|
|
1260
|
+
openReference(source, reference) {
|
|
1261
|
+
if (this.disposed) return false;
|
|
1262
|
+
const session = this.project();
|
|
1263
|
+
for (const owner of this.deps.roster.all()) if ((source === void 0 ? reference.ref.startsWith(owner.trigger) && owner.lexicon?.(session)?.includes(reference.ref.slice(1)) : owner.name === source) && owner.openReference?.(session, reference)) {
|
|
1264
|
+
this.dismiss();
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
return false;
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Enter last adjudication: polls sources' matchEnter in registration
|
|
1271
|
+
* order, first non-undefined wins. The outcome returns to the caller (the
|
|
1272
|
+
* input machine applies it inside the same submit attempt — no event).
|
|
1273
|
+
* @param line - trimmed draft; the leading char selects the trigger roster.
|
|
1274
|
+
* @param signal - attempt-scoped abort from the input machine.
|
|
1275
|
+
* @param envelope - non-text submission state accompanying the draft.
|
|
1276
|
+
* @returns the winning outcome or undefined (default sink). Rejects when a
|
|
1277
|
+
* polled source's warmup fails or the winning source refuses the envelope —
|
|
1278
|
+
* the caller must not silently downgrade.
|
|
1279
|
+
*/
|
|
1280
|
+
async adjudicate(line, signal, envelope) {
|
|
1281
|
+
const projection = this.project();
|
|
1282
|
+
for (const src of this.deps.roster.all()) {
|
|
1283
|
+
if (signal.aborted) throw signal.reason instanceof Error ? signal.reason : /* @__PURE__ */ new Error("slash adjudication aborted");
|
|
1284
|
+
if (src.matchEnter === void 0 || !line.startsWith(src.trigger)) continue;
|
|
1285
|
+
const outcome = await src.matchEnter(projection, line, signal, envelope);
|
|
1286
|
+
if (outcome !== void 0) return outcome;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Drop the menu group of a disposed source (root registry change notification).
|
|
1291
|
+
* @param source - the source whose registration was disposed.
|
|
1292
|
+
*/
|
|
1293
|
+
sourceRemoved(source) {
|
|
1294
|
+
const state = this.menu.getSnapshot();
|
|
1295
|
+
if (state.open && state.hit !== null && state.hit.trigger === source.trigger) this.reduce({
|
|
1296
|
+
type: "source-failed",
|
|
1297
|
+
generation: state.generation,
|
|
1298
|
+
source: source.name
|
|
1299
|
+
});
|
|
1300
|
+
this.lexiconOffs.get(source)?.();
|
|
1301
|
+
this.lexiconOffs.delete(source);
|
|
1302
|
+
this.refreshLexicon();
|
|
1303
|
+
}
|
|
1304
|
+
/**
|
|
1305
|
+
* Admit a source registered after this controller's birth (root registry
|
|
1306
|
+
* change notification): warm it and fold its roll into the live lexicon —
|
|
1307
|
+
* the constructor-time prewarm covers only the roster present at scope
|
|
1308
|
+
* birth.
|
|
1309
|
+
* @param source - the newly registered source.
|
|
1310
|
+
*/
|
|
1311
|
+
sourceAdded(source) {
|
|
1312
|
+
const projection = this.project();
|
|
1313
|
+
source.warm?.(projection);
|
|
1314
|
+
this.watchLexicon(source, projection);
|
|
1315
|
+
this.refreshLexicon();
|
|
1316
|
+
}
|
|
1317
|
+
/** External dismiss (e.g. pointer outside the composer area). */
|
|
1318
|
+
dismiss() {
|
|
1319
|
+
if (this.disposed) return;
|
|
1320
|
+
this.rememberDismissed();
|
|
1321
|
+
this.stopFetch();
|
|
1322
|
+
this.reduce({ type: "close" });
|
|
1323
|
+
}
|
|
1324
|
+
/** Re-fetch the currently open menu without changing its hit or visible rows. */
|
|
1325
|
+
refreshOpenMenu() {
|
|
1326
|
+
if (this.disposed || !this.menu.getSnapshot().open || this.hit === null) return;
|
|
1327
|
+
const launched = this.launcher.getSnapshot();
|
|
1328
|
+
const roster = this.deps.roster.sources(this.hit.trigger).filter((source) => launched === null || source.name === launched);
|
|
1329
|
+
if (roster.length === 0) return;
|
|
1330
|
+
this.fetchCandidates(this.hit, roster);
|
|
1331
|
+
}
|
|
1332
|
+
/** Scope teardown: close and abort (the service deletes the map entry). */
|
|
1333
|
+
dispose() {
|
|
1334
|
+
this.disposed = true;
|
|
1335
|
+
this.stopFetch();
|
|
1336
|
+
this.reduce({ type: "close" });
|
|
1337
|
+
this.hit = null;
|
|
1338
|
+
for (const off of this.lexiconOffs.values()) off();
|
|
1339
|
+
this.lexiconOffs.clear();
|
|
1340
|
+
}
|
|
1341
|
+
/** The session projection handed to sources (agent-backed identity; constant per scope). */
|
|
1342
|
+
project() {
|
|
1343
|
+
return { sessionId: this.deps.sessionId };
|
|
1344
|
+
}
|
|
1345
|
+
/** Execute a claim/insert/text outcome via the scoped input events (actx as dispatch subject); true = the input applied it. */
|
|
1346
|
+
execute(outcome, span) {
|
|
1347
|
+
const { actx } = this.deps;
|
|
1348
|
+
if (outcome === void 0 || outcome === "handled") return false;
|
|
1349
|
+
if ("claim" in outcome) return actx.bail(actx, "slash/input-begin-command", {
|
|
1350
|
+
claim: outcome.claim,
|
|
1351
|
+
span
|
|
1352
|
+
}) === true;
|
|
1353
|
+
if ("text" in outcome) return actx.bail(actx, "slash/input-insert-text", {
|
|
1354
|
+
text: outcome.text,
|
|
1355
|
+
span,
|
|
1356
|
+
...outcome.continue === true ? { continue: true } : {}
|
|
1357
|
+
}) === true;
|
|
1358
|
+
return actx.bail(actx, "slash/input-insert-reference", {
|
|
1359
|
+
reference: outcome.insert,
|
|
1360
|
+
span
|
|
1361
|
+
}) === true;
|
|
1362
|
+
}
|
|
1363
|
+
/** Re-poll every lexicon-bearing source and publish the aggregated rolls (see the store doc). */
|
|
1364
|
+
refreshLexicon() {
|
|
1365
|
+
const projection = this.project();
|
|
1366
|
+
const rolls = /* @__PURE__ */ new Map();
|
|
1367
|
+
for (const src of this.deps.roster.all()) {
|
|
1368
|
+
if (src.lexicon === void 0) continue;
|
|
1369
|
+
let names;
|
|
1370
|
+
try {
|
|
1371
|
+
names = src.lexicon(projection);
|
|
1372
|
+
} catch (error) {
|
|
1373
|
+
console.error(`[ui-input-trigger] source "${src.name}" lexicon failed:`, error);
|
|
1374
|
+
continue;
|
|
1375
|
+
}
|
|
1376
|
+
if (names === void 0) continue;
|
|
1377
|
+
const prev = rolls.get(src.trigger);
|
|
1378
|
+
rolls.set(src.trigger, prev === void 0 ? names : [...prev, ...names]);
|
|
1379
|
+
}
|
|
1380
|
+
this.lexicon.set(rolls);
|
|
1381
|
+
}
|
|
1382
|
+
/** Wire one source's lexicon invalidation channel into refresh (hookless or roll-less sources never notify). */
|
|
1383
|
+
watchLexicon(source, projection) {
|
|
1384
|
+
if (source.lexicon === void 0 || source.subscribeLexicon === void 0) return;
|
|
1385
|
+
this.lexiconOffs.set(source, source.subscribeLexicon(projection, () => {
|
|
1386
|
+
this.refreshLexicon();
|
|
1387
|
+
const hit = this.hit;
|
|
1388
|
+
if (hit === null || !this.menu.getSnapshot().open || hit.trigger !== source.trigger) return;
|
|
1389
|
+
Promise.resolve().then(() => {
|
|
1390
|
+
if (this.disposed || this.hit !== hit || !this.menu.getSnapshot().open) return;
|
|
1391
|
+
this.fetchCandidates(hit, this.deps.roster.sources(hit.trigger));
|
|
1392
|
+
});
|
|
1393
|
+
}));
|
|
1394
|
+
}
|
|
1395
|
+
/** Launch the candidate fetch for one hit generation, superseding the previous one. */
|
|
1396
|
+
fetchCandidates(hit, roster) {
|
|
1397
|
+
this.stopFetch();
|
|
1398
|
+
const controller = new AbortController();
|
|
1399
|
+
this.fetch = controller;
|
|
1400
|
+
const generation = this.menu.getSnapshot().generation;
|
|
1401
|
+
const projection = this.project();
|
|
1402
|
+
for (const source of roster) source.candidates(projection, {
|
|
1403
|
+
query: hit.query,
|
|
1404
|
+
quoted: hit.quoted,
|
|
1405
|
+
position: hit.position,
|
|
1406
|
+
drilled: this.drilled,
|
|
1407
|
+
signal: controller.signal
|
|
1408
|
+
}).then((items) => {
|
|
1409
|
+
if (controller.signal.aborted) return;
|
|
1410
|
+
this.reduce({
|
|
1411
|
+
type: "source-settled",
|
|
1412
|
+
generation,
|
|
1413
|
+
source: source.name,
|
|
1414
|
+
items
|
|
1415
|
+
});
|
|
1416
|
+
}, (error) => {
|
|
1417
|
+
if (controller.signal.aborted) return;
|
|
1418
|
+
console.error(`[ui-input-trigger] source "${source.name}" candidates failed:`, error);
|
|
1419
|
+
this.reduce({
|
|
1420
|
+
type: "source-failed",
|
|
1421
|
+
generation,
|
|
1422
|
+
source: source.name
|
|
1423
|
+
});
|
|
1424
|
+
});
|
|
1425
|
+
}
|
|
1426
|
+
stopFetch() {
|
|
1427
|
+
this.fetch?.abort();
|
|
1428
|
+
this.fetch = null;
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Run one candidate (or crumb) through its source and apply the outcome.
|
|
1432
|
+
*
|
|
1433
|
+
* A drill is the one pick that leaves the menu open, so it is also the one
|
|
1434
|
+
* that records how the next query was reached; every other pick closes the
|
|
1435
|
+
* menu, which clears that record.
|
|
1436
|
+
* @param src - the owning source.
|
|
1437
|
+
* @param candidate - the picked candidate, or a crumb projected as one.
|
|
1438
|
+
* @param hit - the authoritative hit supplying position and span CAS.
|
|
1439
|
+
* @param action - settling pick or drill.
|
|
1440
|
+
*/
|
|
1441
|
+
settle(src, candidate, hit, action) {
|
|
1442
|
+
const outcome = src.onPick({
|
|
1443
|
+
candidate,
|
|
1444
|
+
session: this.project(),
|
|
1445
|
+
position: hit.position,
|
|
1446
|
+
via: "menu",
|
|
1447
|
+
action,
|
|
1448
|
+
span: hit.span
|
|
1449
|
+
});
|
|
1450
|
+
this.stopFetch();
|
|
1451
|
+
if (action === "pick") this.rememberDismissed();
|
|
1452
|
+
this.reduce({ type: "close" });
|
|
1453
|
+
this.drilled = action === "drill";
|
|
1454
|
+
if (!this.execute(outcome, hit.span)) this.drilled = false;
|
|
1455
|
+
}
|
|
1456
|
+
/** Re-poll every header-bearing source in the hit roster and publish their crumbs. */
|
|
1457
|
+
refreshHeaders(hit, roster) {
|
|
1458
|
+
const projection = this.project();
|
|
1459
|
+
const crumbs = /* @__PURE__ */ new Map();
|
|
1460
|
+
for (const src of roster) {
|
|
1461
|
+
if (src.header === void 0) continue;
|
|
1462
|
+
let published;
|
|
1463
|
+
try {
|
|
1464
|
+
published = src.header(projection, {
|
|
1465
|
+
query: hit.query,
|
|
1466
|
+
quoted: hit.quoted,
|
|
1467
|
+
drilled: this.drilled
|
|
1468
|
+
});
|
|
1469
|
+
} catch (error) {
|
|
1470
|
+
console.error(`[ui-input-trigger] source "${src.name}" header failed:`, error);
|
|
1471
|
+
continue;
|
|
1472
|
+
}
|
|
1473
|
+
if (published === void 0 || published.length === 0) continue;
|
|
1474
|
+
crumbs.set(src.name, published);
|
|
1475
|
+
}
|
|
1476
|
+
this.setHeaders(crumbs);
|
|
1477
|
+
}
|
|
1478
|
+
setHeaders(next) {
|
|
1479
|
+
if (this.headers.getSnapshot().size === 0 && next.size === 0) return;
|
|
1480
|
+
this.headers.set(next);
|
|
1481
|
+
}
|
|
1482
|
+
/** Record the open menu's identity as dismissed, so a bare re-track cannot revive it. */
|
|
1483
|
+
rememberDismissed() {
|
|
1484
|
+
const hit = this.hit;
|
|
1485
|
+
this.dismissed = hit === null ? null : {
|
|
1486
|
+
trigger: hit.trigger,
|
|
1487
|
+
query: hit.query,
|
|
1488
|
+
quoted: hit.quoted,
|
|
1489
|
+
start: hit.span.start,
|
|
1490
|
+
end: hit.span.end
|
|
1491
|
+
};
|
|
1492
|
+
}
|
|
1493
|
+
clearLauncher() {
|
|
1494
|
+
if (this.launcher.getSnapshot() !== null) this.launcher.set(null);
|
|
1495
|
+
}
|
|
1496
|
+
reduce(ev) {
|
|
1497
|
+
const cur = this.menu.getSnapshot();
|
|
1498
|
+
const next = menuReduce(cur, ev);
|
|
1499
|
+
if (next !== cur) this.menu.set(next);
|
|
1500
|
+
if (next.open) return;
|
|
1501
|
+
this.clearLauncher();
|
|
1502
|
+
this.drilled = false;
|
|
1503
|
+
this.setHeaders(/* @__PURE__ */ new Map());
|
|
1504
|
+
}
|
|
1505
|
+
};
|
|
1506
|
+
//#endregion
|
|
1507
|
+
//#region lib/types/client/service.js
|
|
1508
|
+
/**
|
|
1509
|
+
* InputTriggerService (`ctx.inputTriggers`): the root half of the trigger pipeline — the
|
|
1510
|
+
* stateless source registry plus the per-session controller map. Every piece
|
|
1511
|
+
* of mutable interaction state (hit, menu, fetch) lives on the
|
|
1512
|
+
* {@link InputTriggerController}; the service only registers sources, resolves
|
|
1513
|
+
* controllers by session scope, and relays roster changes.
|
|
1514
|
+
*/
|
|
1515
|
+
/** The `ctx.inputTriggers` trigger pipeline service (root registry + controller resolution). */
|
|
1516
|
+
var InputTriggerService = class extends _qilin_agent_kylin.Service {
|
|
1517
|
+
static inject = ["sessions"];
|
|
1518
|
+
live = {
|
|
1519
|
+
sources: [],
|
|
1520
|
+
controllers: new WeakMapWithValues()
|
|
1521
|
+
};
|
|
1522
|
+
/**
|
|
1523
|
+
* @param ctx - owning root context (the service registers itself as `slash`).
|
|
1524
|
+
*/
|
|
1525
|
+
constructor(ctx) {
|
|
1526
|
+
super(ctx, "inputTriggers");
|
|
1527
|
+
ctx.on("locale/change", () => {
|
|
1528
|
+
for (const controller of this.live.controllers.values) controller.refreshOpenMenu();
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Register one trigger source. Live session controllers are notified so a
|
|
1533
|
+
* source arriving after scope birth still warms and joins the lexicon.
|
|
1534
|
+
* @param src - the source; (trigger, name) must be unique — duplicates throw.
|
|
1535
|
+
* @returns the disposer (callers wrap registration in ctx.effect). Disposal
|
|
1536
|
+
* while a controller shows the source's menu group drops that group.
|
|
1537
|
+
*/
|
|
1538
|
+
registerSource(src) {
|
|
1539
|
+
const { live } = this;
|
|
1540
|
+
if (live.sources.some((s) => s.trigger === src.trigger && s.name === src.name)) throw new Error(`slash source "${src.trigger}${src.name}" is already registered`);
|
|
1541
|
+
live.sources.push(src);
|
|
1542
|
+
for (const controller of live.controllers.values) try {
|
|
1543
|
+
controller.sourceAdded(src);
|
|
1544
|
+
} catch (error) {
|
|
1545
|
+
console.error(`[ui-input-trigger] source "${src.trigger}${src.name}" late-registration setup failed:`, error);
|
|
1546
|
+
}
|
|
1547
|
+
return () => {
|
|
1548
|
+
const at = live.sources.indexOf(src);
|
|
1549
|
+
if (at < 0) return;
|
|
1550
|
+
live.sources.splice(at, 1);
|
|
1551
|
+
for (const controller of live.controllers.values) controller.sourceRemoved(src);
|
|
1552
|
+
};
|
|
1553
|
+
}
|
|
1554
|
+
/**
|
|
1555
|
+
* Resolve the per-session controller for one session scope (lazy; the
|
|
1556
|
+
* scope disposer removes and disposes it). Construction warms the source
|
|
1557
|
+
* roster once — sessions are always agent-backed, so scope birth is the
|
|
1558
|
+
* single prewarm moment.
|
|
1559
|
+
* @param actx - session-scope ctx.
|
|
1560
|
+
* @returns the resident controller.
|
|
1561
|
+
* @throws when the Context no longer belongs to a retained Session generation.
|
|
1562
|
+
*/
|
|
1563
|
+
sessionOf(actx) {
|
|
1564
|
+
const sessions = this.sessions();
|
|
1565
|
+
const session = sessions.sessionOf(actx);
|
|
1566
|
+
const binding = session === void 0 ? void 0 : sessions.binding(session.sessionId);
|
|
1567
|
+
if (binding === void 0 || binding.session !== session) throw new Error("slash.sessionOf requires a retained Session scope");
|
|
1568
|
+
const id = binding.sessionId;
|
|
1569
|
+
const { live } = this;
|
|
1570
|
+
const existing = live.controllers.get(binding);
|
|
1571
|
+
if (existing !== void 0) return existing;
|
|
1572
|
+
const controller = new InputTriggerController({
|
|
1573
|
+
actx: binding.ctx,
|
|
1574
|
+
sessionId: id,
|
|
1575
|
+
roster: {
|
|
1576
|
+
sources: (trigger) => live.sources.filter((s) => s.trigger === trigger).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)),
|
|
1577
|
+
all: () => live.sources
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
live.controllers.set(binding, controller);
|
|
1581
|
+
binding.ctx.effect(() => () => {
|
|
1582
|
+
controller.dispose();
|
|
1583
|
+
live.controllers.delete(binding);
|
|
1584
|
+
}, "slash: session controller");
|
|
1585
|
+
return controller;
|
|
1586
|
+
}
|
|
1587
|
+
sessions() {
|
|
1588
|
+
const sessions = this.ctx.get("sessions");
|
|
1589
|
+
if (sessions === void 0) throw new Error("ui-input-trigger: sessions service unavailable");
|
|
1590
|
+
return sessions;
|
|
1591
|
+
}
|
|
1592
|
+
};
|
|
1593
|
+
//#endregion
|
|
1594
|
+
//#region ../../../node_modules/.pnpm/clsx@2.1.1/node_modules/clsx/dist/clsx.mjs
|
|
1595
|
+
function r(e) {
|
|
1596
|
+
var t, f, n = "";
|
|
1597
|
+
if ("string" == typeof e || "number" == typeof e) n += e;
|
|
1598
|
+
else if ("object" == typeof e) if (Array.isArray(e)) {
|
|
1599
|
+
var o = e.length;
|
|
1600
|
+
for (t = 0; t < o; t++) e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
|
|
1601
|
+
} else for (f in e) e[f] && (n && (n += " "), n += f);
|
|
1602
|
+
return n;
|
|
1603
|
+
}
|
|
1604
|
+
function clsx() {
|
|
1605
|
+
for (var e, t, f = 0, n = "", o = arguments.length; f < o; f++) (e = arguments[f]) && (t = r(e)) && (n && (n += " "), n += t);
|
|
1606
|
+
return n;
|
|
1607
|
+
}
|
|
1608
|
+
//#endregion
|
|
1609
|
+
//#region \0qilin-css:/home/runner/work/QiLin/QiLin/packages/client/ui-input-trigger/src/client/MenuView.module.css.mjs
|
|
1610
|
+
const css = ".-LmocW_menu{z-index:100;box-sizing:border-box;--qilin-scrollbar-thumb:var(--qilin-alias-scrollbar-bg-l2);--qilin-scrollbar-thumb-hover:var(--qilin-alias-scrollbar-hover-l2);--qilin-scrollbar-width:10px;--qilin-scrollbar-thumb-border:3px;--qilin-scrollbar-track-margin:12px;background:var(--qilin-specific-menu);--qilin-elevation-stroke-color:var(--qilin-alias-border-l1);max-height:400px;box-shadow:var(--qilin-elevation-prominent);border:0;border-radius:20px;flex-direction:column;padding:4px;display:flex;position:absolute;bottom:calc(100% + 4px);left:0;right:0;overflow:hidden}.-LmocW_menu[data-overflow-below]:after{content:\"\";background:linear-gradient(to bottom, transparent, var(--qilin-specific-menu));pointer-events:none;height:16px;position:absolute;bottom:4px;left:4px;right:14px}.-LmocW_viewport{flex-direction:column;min-height:0;display:flex;overflow-y:auto}.-LmocW_item{cursor:pointer;width:100%;min-height:40px;color:var(--qilin-alias-label-primary);text-align:left;background:0 0;border:none;border-radius:10px;align-items:center;gap:8px;padding:8px 10px;font-size:14px;line-height:22px;display:flex}.-LmocW_item.-LmocW_active{background:var(--qilin-alias-interactive-bg-hover)}.-LmocW_sectionTitle{min-height:26px;color:var(--qilin-alias-label-tertiary);flex:none;padding:6px 10px 2px;font-size:12px;font-weight:500;line-height:18px}.-LmocW_sectionTitle:not(:first-child){margin-top:4px}.-LmocW_itemIcon{width:16px;height:16px;color:var(--qilin-alias-label-tertiary);flex:none;justify-content:center;align-items:center;display:inline-flex}.-LmocW_itemName{text-overflow:ellipsis;white-space:nowrap;flex:none;max-width:40%;overflow:hidden}.-LmocW_itemAlias{text-overflow:ellipsis;white-space:nowrap;max-width:20%;color:var(--qilin-alias-label-tertiary);flex:none;overflow:hidden}.-LmocW_itemDescription{text-overflow:ellipsis;white-space:nowrap;text-align:right;min-width:0;color:var(--qilin-alias-label-tertiary);flex:1;overflow:hidden}.-LmocW_trailing{flex:none;align-items:center;gap:4px;margin-left:auto;display:inline-flex}.-LmocW_drillHintText{color:var(--qilin-alias-label-caption);white-space:nowrap;font-size:11px;line-height:18px;display:none}.-LmocW_drillHint{background:var(--qilin-alias-interactive-bg-hover);color:var(--qilin-alias-label-caption);border-radius:4px;padding:0 5px;font-family:inherit;font-size:11px;line-height:18px;display:none}.-LmocW_item.-LmocW_active .-LmocW_drillHintText,.-LmocW_item.-LmocW_active .-LmocW_drillHint{display:inline-flex}.-LmocW_drill{width:20px;height:20px;color:var(--qilin-alias-label-caption);border-radius:4px;flex:none;place-items:center;display:inline-grid}.-LmocW_drill:hover{background:var(--qilin-alias-interactive-bg-hover);color:var(--qilin-alias-label-primary)}.-LmocW_groupTitle{color:var(--qilin-alias-label-tertiary);padding:8px 10px;font-size:12px;line-height:16px}.-LmocW_skeletonRow{box-sizing:border-box;align-items:center;min-height:40px;padding:8px 10px;display:flex}.-LmocW_skeletonBar{background:var(--qilin-alias-bg-skeleton);border-radius:4px;height:20px;animation:2s cubic-bezier(.36,0,.64,1) infinite -LmocW_qilin-menu-skeleton}@keyframes -LmocW_qilin-menu-skeleton{0%{opacity:1}40%{opacity:.6}80%,to{opacity:1}}.-LmocW_crumbs{border-bottom:.5px solid var(--qilin-alias-border-l1);flex-wrap:wrap;flex:none;align-items:center;gap:2px;margin-bottom:2px;padding:4px 4px 6px;display:flex}.-LmocW_crumb{max-width:40%;color:var(--qilin-alias-label-tertiary);cursor:pointer;text-overflow:ellipsis;white-space:nowrap;background:0 0;border:none;border-radius:6px;flex:0 auto;padding:2px 6px;font-family:inherit;font-size:12px;line-height:18px;overflow:hidden}.-LmocW_crumb:hover{background:var(--qilin-alias-interactive-bg-hover);color:var(--qilin-alias-label-primary)}.-LmocW_crumbCurrent,.-LmocW_crumbCurrent:hover{color:var(--qilin-alias-label-primary);cursor:default;background:0 0}.-LmocW_crumbSeparator{color:var(--qilin-alias-label-caption);flex:none;display:inline-flex}";
|
|
1611
|
+
const tagId = "@qilin-agent/client-ui-input-trigger/MenuView.module.css";
|
|
1612
|
+
if (typeof document !== "undefined" && document.querySelector("style[data-plugin-css=" + JSON.stringify(tagId) + "]") === null) {
|
|
1613
|
+
const tag = document.createElement("style");
|
|
1614
|
+
tag.dataset.plugin = "@qilin-agent/client-ui-input-trigger";
|
|
1615
|
+
tag.dataset.pluginCss = tagId;
|
|
1616
|
+
tag.textContent = css;
|
|
1617
|
+
document.head.appendChild(tag);
|
|
1618
|
+
}
|
|
1619
|
+
var MenuView_module_css_default = {
|
|
1620
|
+
"active": "-LmocW_active",
|
|
1621
|
+
"crumb": "-LmocW_crumb",
|
|
1622
|
+
"crumbCurrent": "-LmocW_crumbCurrent",
|
|
1623
|
+
"crumbSeparator": "-LmocW_crumbSeparator",
|
|
1624
|
+
"crumbs": "-LmocW_crumbs",
|
|
1625
|
+
"drill": "-LmocW_drill",
|
|
1626
|
+
"drillHint": "-LmocW_drillHint",
|
|
1627
|
+
"drillHintText": "-LmocW_drillHintText",
|
|
1628
|
+
"groupTitle": "-LmocW_groupTitle",
|
|
1629
|
+
"item": "-LmocW_item",
|
|
1630
|
+
"itemAlias": "-LmocW_itemAlias",
|
|
1631
|
+
"itemDescription": "-LmocW_itemDescription",
|
|
1632
|
+
"itemIcon": "-LmocW_itemIcon",
|
|
1633
|
+
"itemName": "-LmocW_itemName",
|
|
1634
|
+
"menu": "-LmocW_menu",
|
|
1635
|
+
"qilin-menu-skeleton": "-LmocW_qilin-menu-skeleton",
|
|
1636
|
+
"sectionTitle": "-LmocW_sectionTitle",
|
|
1637
|
+
"skeletonBar": "-LmocW_skeletonBar",
|
|
1638
|
+
"skeletonRow": "-LmocW_skeletonRow",
|
|
1639
|
+
"trailing": "-LmocW_trailing",
|
|
1640
|
+
"viewport": "-LmocW_viewport"
|
|
1641
|
+
};
|
|
1642
|
+
//#endregion
|
|
1643
|
+
//#region lib/types/client/MenuView.js
|
|
1644
|
+
/**
|
|
1645
|
+
* Trigger candidate menu: renders the InputTriggerService menu store into the
|
|
1646
|
+
* conversation.input.overlay anchor. Closed state renders null (the overlay
|
|
1647
|
+
* slot stays mounted); groups render in roster order under localized title
|
|
1648
|
+
* rows. A pending group keeps showing the items it already had (the reducer
|
|
1649
|
+
* retains them across a query refinement) and falls back to two skeleton
|
|
1650
|
+
* rows only while it has none; pointer picks route back through
|
|
1651
|
+
* the service (combobox pattern — focus never leaves the textarea, so rows
|
|
1652
|
+
* are mousedown-handled and the highlight is exposed via
|
|
1653
|
+
* aria-activedescendant on the listbox). A row reads title, then the
|
|
1654
|
+
* command-name alias when the title is not the name in another letter case
|
|
1655
|
+
* (a localized title), then the description right-aligned. A source publishing crumbs gets a breadcrumb
|
|
1656
|
+
* header pinned above the scrolling list.
|
|
1657
|
+
*/
|
|
1658
|
+
/** Height cap that fits the two headings and eight built-in command rows. */
|
|
1659
|
+
const MAX_HEIGHT = 400;
|
|
1660
|
+
/** DOM id of one option row (the aria-activedescendant target). */
|
|
1661
|
+
function optionId(source, index) {
|
|
1662
|
+
return `qilin-slash-option-${source}-${index}`;
|
|
1663
|
+
}
|
|
1664
|
+
/**
|
|
1665
|
+
* Render the candidate menu overlay entry.
|
|
1666
|
+
* @param props - injected face (the menu store and the pick route); `t` rides the standard locale seat.
|
|
1667
|
+
* @returns the dropdown while open; null while closed.
|
|
1668
|
+
*/
|
|
1669
|
+
function MenuView({ menu, headers, onPick, onCrumb, onHover, onDismiss, t }) {
|
|
1670
|
+
const state = (0, react.useSyncExternalStore)((fn) => menu.subscribe(fn), () => menu.getSnapshot());
|
|
1671
|
+
const crumbs = (0, react.useSyncExternalStore)((fn) => headers.subscribe(fn), () => headers.getSnapshot());
|
|
1672
|
+
const listRef = (0, react.useRef)(null);
|
|
1673
|
+
const viewportRef = (0, react.useRef)(null);
|
|
1674
|
+
const [hasOverflowBelow, setHasOverflowBelow] = (0, react.useState)(false);
|
|
1675
|
+
const maxHeight = (0, _qilin_agent_client_ui_primitives.useAnchoredMaxHeight)(listRef, MAX_HEIGHT, state);
|
|
1676
|
+
const updateOverflowHint = (0, react.useCallback)(() => {
|
|
1677
|
+
const viewport = viewportRef.current;
|
|
1678
|
+
setHasOverflowBelow(viewport !== null && viewport.scrollTop + viewport.clientHeight < viewport.scrollHeight - 1);
|
|
1679
|
+
}, []);
|
|
1680
|
+
(0, react.useLayoutEffect)(() => {
|
|
1681
|
+
updateOverflowHint();
|
|
1682
|
+
}, [
|
|
1683
|
+
state,
|
|
1684
|
+
maxHeight,
|
|
1685
|
+
updateOverflowHint
|
|
1686
|
+
]);
|
|
1687
|
+
const highlight = state.open ? state.highlight : null;
|
|
1688
|
+
(0, react.useEffect)(() => {
|
|
1689
|
+
if (highlight === null) return;
|
|
1690
|
+
document.getElementById(optionId(highlight.source, highlight.index))?.scrollIntoView({ block: "nearest" });
|
|
1691
|
+
}, [highlight]);
|
|
1692
|
+
(0, react.useEffect)(() => {
|
|
1693
|
+
if (!state.open) return;
|
|
1694
|
+
const onPointerDown = (ev) => {
|
|
1695
|
+
if (!(ev.target instanceof Node)) return;
|
|
1696
|
+
if (listRef.current?.contains(ev.target)) return;
|
|
1697
|
+
if ((listRef.current?.closest("[data-composer-card]"))?.contains(ev.target)) return;
|
|
1698
|
+
onDismiss();
|
|
1699
|
+
};
|
|
1700
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
1701
|
+
return () => {
|
|
1702
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
1703
|
+
};
|
|
1704
|
+
}, [state.open, onDismiss]);
|
|
1705
|
+
if (!state.open) return null;
|
|
1706
|
+
return (0, react_jsx_runtime.jsxs)("div", {
|
|
1707
|
+
ref: listRef,
|
|
1708
|
+
className: MenuView_module_css_default.menu,
|
|
1709
|
+
style: { maxHeight },
|
|
1710
|
+
"data-trigger-menu": "",
|
|
1711
|
+
"data-overflow-below": hasOverflowBelow || void 0,
|
|
1712
|
+
children: [state.groups.map((group) => {
|
|
1713
|
+
const trail = crumbs.get(group.source);
|
|
1714
|
+
return trail === void 0 ? null : (0, react_jsx_runtime.jsx)("nav", {
|
|
1715
|
+
className: MenuView_module_css_default.crumbs,
|
|
1716
|
+
"aria-label": t("crumbs.aria"),
|
|
1717
|
+
children: trail.map((crumb, index) => (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [index > 0 && (0, react_jsx_runtime.jsx)("span", {
|
|
1718
|
+
className: MenuView_module_css_default.crumbSeparator,
|
|
1719
|
+
"aria-hidden": true,
|
|
1720
|
+
children: (0, react_jsx_runtime.jsx)(_qilin_agent_client_ui_primitives.IconChevronRightOutline14, {})
|
|
1721
|
+
}), (0, react_jsx_runtime.jsx)("button", {
|
|
1722
|
+
type: "button",
|
|
1723
|
+
className: clsx(MenuView_module_css_default.crumb, crumb.current === true && MenuView_module_css_default.crumbCurrent),
|
|
1724
|
+
"aria-current": crumb.current === true ? "location" : void 0,
|
|
1725
|
+
disabled: crumb.current === true,
|
|
1726
|
+
onMouseDown: (ev) => {
|
|
1727
|
+
ev.preventDefault();
|
|
1728
|
+
onCrumb(group.source, index);
|
|
1729
|
+
},
|
|
1730
|
+
children: crumb.label
|
|
1731
|
+
})] }, `${String(index)}-${crumb.value}`))
|
|
1732
|
+
}, group.source);
|
|
1733
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
1734
|
+
ref: viewportRef,
|
|
1735
|
+
className: MenuView_module_css_default.viewport,
|
|
1736
|
+
role: "listbox",
|
|
1737
|
+
"aria-label": t("suggestions.aria"),
|
|
1738
|
+
"aria-activedescendant": highlight !== null ? optionId(highlight.source, highlight.index) : void 0,
|
|
1739
|
+
onScroll: updateOverflowHint,
|
|
1740
|
+
children: state.groups.map((group) => group.status === "ready" && group.items.length === 0 ? null : (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [group.showGroupTitle === false || group.items.some((item) => item.section !== void 0) ? null : (0, react_jsx_runtime.jsx)("div", {
|
|
1741
|
+
className: MenuView_module_css_default.groupTitle,
|
|
1742
|
+
role: "presentation",
|
|
1743
|
+
"data-source": group.source,
|
|
1744
|
+
children: t(group.source)
|
|
1745
|
+
}), group.status === "pending" && group.items.length === 0 ? (0, react_jsx_runtime.jsxs)("div", {
|
|
1746
|
+
role: "status",
|
|
1747
|
+
"aria-label": t("loading"),
|
|
1748
|
+
"data-source": group.source,
|
|
1749
|
+
children: [(0, react_jsx_runtime.jsx)("div", {
|
|
1750
|
+
className: MenuView_module_css_default.skeletonRow,
|
|
1751
|
+
children: (0, react_jsx_runtime.jsx)("span", {
|
|
1752
|
+
className: MenuView_module_css_default.skeletonBar,
|
|
1753
|
+
style: { width: "32%" }
|
|
1754
|
+
})
|
|
1755
|
+
}), (0, react_jsx_runtime.jsx)("div", {
|
|
1756
|
+
className: MenuView_module_css_default.skeletonRow,
|
|
1757
|
+
children: (0, react_jsx_runtime.jsx)("span", {
|
|
1758
|
+
className: MenuView_module_css_default.skeletonBar,
|
|
1759
|
+
style: { width: "48%" }
|
|
1760
|
+
})
|
|
1761
|
+
})]
|
|
1762
|
+
}) : group.items.map((item, index) => {
|
|
1763
|
+
const active = highlight !== null && highlight.source === group.source && highlight.index === index;
|
|
1764
|
+
return (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [item.section !== void 0 && item.section !== group.items[index - 1]?.section ? (0, react_jsx_runtime.jsx)("div", {
|
|
1765
|
+
className: MenuView_module_css_default.sectionTitle,
|
|
1766
|
+
role: "presentation",
|
|
1767
|
+
children: item.section
|
|
1768
|
+
}) : null, (0, react_jsx_runtime.jsxs)("button", {
|
|
1769
|
+
id: optionId(group.source, index),
|
|
1770
|
+
type: "button",
|
|
1771
|
+
role: "option",
|
|
1772
|
+
"aria-selected": active,
|
|
1773
|
+
className: clsx(MenuView_module_css_default.item, active && MenuView_module_css_default.active),
|
|
1774
|
+
onMouseDown: (ev) => {
|
|
1775
|
+
ev.preventDefault();
|
|
1776
|
+
onPick(group.source, index);
|
|
1777
|
+
},
|
|
1778
|
+
onMouseMove: active ? void 0 : () => {
|
|
1779
|
+
onHover(group.source, index);
|
|
1780
|
+
},
|
|
1781
|
+
children: [
|
|
1782
|
+
item.icon !== void 0 && (0, react_jsx_runtime.jsx)("span", {
|
|
1783
|
+
className: MenuView_module_css_default.itemIcon,
|
|
1784
|
+
"aria-hidden": true,
|
|
1785
|
+
children: typeof item.icon === "string" ? (0, react_jsx_runtime.jsx)(_qilin_agent_client_ui_primitives.ReferenceIcon, {
|
|
1786
|
+
kind: item.icon,
|
|
1787
|
+
size: 16
|
|
1788
|
+
}) : (0, react_jsx_runtime.jsx)(item.icon, { size: 16 })
|
|
1789
|
+
}),
|
|
1790
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1791
|
+
className: MenuView_module_css_default.itemName,
|
|
1792
|
+
children: item.label ?? item.name
|
|
1793
|
+
}),
|
|
1794
|
+
item.label !== void 0 && item.label.toLowerCase() !== item.name.toLowerCase() && (0, react_jsx_runtime.jsx)("span", {
|
|
1795
|
+
className: MenuView_module_css_default.itemAlias,
|
|
1796
|
+
children: item.name
|
|
1797
|
+
}),
|
|
1798
|
+
item.description !== void 0 && (0, react_jsx_runtime.jsx)("span", {
|
|
1799
|
+
className: MenuView_module_css_default.itemDescription,
|
|
1800
|
+
children: item.description
|
|
1801
|
+
}),
|
|
1802
|
+
item.drill === true && (0, react_jsx_runtime.jsxs)("span", {
|
|
1803
|
+
className: MenuView_module_css_default.trailing,
|
|
1804
|
+
children: [
|
|
1805
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1806
|
+
className: MenuView_module_css_default.drillHintText,
|
|
1807
|
+
"aria-hidden": true,
|
|
1808
|
+
children: t("drill.hint")
|
|
1809
|
+
}),
|
|
1810
|
+
(0, react_jsx_runtime.jsx)("kbd", {
|
|
1811
|
+
className: MenuView_module_css_default.drillHint,
|
|
1812
|
+
"aria-hidden": true,
|
|
1813
|
+
children: t("drill.key")
|
|
1814
|
+
}),
|
|
1815
|
+
(0, react_jsx_runtime.jsx)("span", {
|
|
1816
|
+
role: "button",
|
|
1817
|
+
"aria-label": t("drill.aria"),
|
|
1818
|
+
className: MenuView_module_css_default.drill,
|
|
1819
|
+
onMouseDown: (ev) => {
|
|
1820
|
+
ev.preventDefault();
|
|
1821
|
+
ev.stopPropagation();
|
|
1822
|
+
onPick(group.source, index, "drill");
|
|
1823
|
+
},
|
|
1824
|
+
children: (0, react_jsx_runtime.jsx)(_qilin_agent_client_ui_primitives.IconChevronRightOutline14, {})
|
|
1825
|
+
})
|
|
1826
|
+
]
|
|
1827
|
+
})
|
|
1828
|
+
]
|
|
1829
|
+
})] }, optionId(group.source, index));
|
|
1830
|
+
})] }, group.source))
|
|
1831
|
+
})]
|
|
1832
|
+
});
|
|
1833
|
+
}
|
|
1834
|
+
//#endregion
|
|
1835
|
+
//#region lib/types/client/locales.js
|
|
1836
|
+
/**
|
|
1837
|
+
* `slash.menu` namespace dictionaries: group titles keyed by source name
|
|
1838
|
+
* (the lookup chain returns the key itself, so an unknown source shows its
|
|
1839
|
+
* raw name), the pending row, and the listbox and header aria labels.
|
|
1840
|
+
*/
|
|
1841
|
+
/** Simplified Chinese dictionary (the key-set source of truth). */
|
|
1842
|
+
const zh = {
|
|
1843
|
+
"command": "指令",
|
|
1844
|
+
"skill": "技能",
|
|
1845
|
+
"subagent": "子智能体",
|
|
1846
|
+
"loading": "正在加载…",
|
|
1847
|
+
"drill.aria": "进入目录",
|
|
1848
|
+
"drill.hint": "进入目录",
|
|
1849
|
+
"drill.key": "Tab",
|
|
1850
|
+
"crumbs.aria": "目录导航",
|
|
1851
|
+
"suggestions.aria": "触发候选建议"
|
|
1852
|
+
};
|
|
1853
|
+
/** English dictionary, checked complete against the zh key set. */
|
|
1854
|
+
const en = {
|
|
1855
|
+
"command": "Commands",
|
|
1856
|
+
"skill": "Skills",
|
|
1857
|
+
"subagent": "Subagents",
|
|
1858
|
+
"loading": "Loading…",
|
|
1859
|
+
"drill.aria": "Browse folder",
|
|
1860
|
+
"drill.hint": "Browse folder",
|
|
1861
|
+
"drill.key": "Tab",
|
|
1862
|
+
"crumbs.aria": "Folder navigation",
|
|
1863
|
+
"suggestions.aria": "Trigger suggestions"
|
|
1864
|
+
};
|
|
1865
|
+
//#endregion
|
|
1866
|
+
//#region lib/types/client/index.js
|
|
1867
|
+
/** Namespace owning the candidate-menu copy. */
|
|
1868
|
+
const MENU_NS = "slash.menu";
|
|
1869
|
+
/** Required services: controller resolution reads the session scope tree; the menu copy is localized. */
|
|
1870
|
+
const inject = ["sessions", "locale"];
|
|
1871
|
+
/**
|
|
1872
|
+
* Client plugin body: mount the service, then register MenuView into the
|
|
1873
|
+
* input overlay once its declarer is up.
|
|
1874
|
+
* @param ctx - client root context.
|
|
1875
|
+
*/
|
|
1876
|
+
function apply(ctx) {
|
|
1877
|
+
ctx.plugin(InputTriggerService);
|
|
1878
|
+
ctx.effect(() => ctx.locale.register(MENU_NS, {
|
|
1879
|
+
zh,
|
|
1880
|
+
en
|
|
1881
|
+
}), "ui-input-trigger: menu dictionaries");
|
|
1882
|
+
ctx.inject([
|
|
1883
|
+
"slots",
|
|
1884
|
+
"inputTriggers",
|
|
1885
|
+
"sessions"
|
|
1886
|
+
], (scope) => {
|
|
1887
|
+
const inputTriggers = scope.inputTriggers;
|
|
1888
|
+
const sessions = scope.sessions;
|
|
1889
|
+
scope.slots.inject("conversation.input.overlay", () => scope.slots.register({
|
|
1890
|
+
name: "conversation.input.overlay",
|
|
1891
|
+
id: "slash-menu",
|
|
1892
|
+
order: 0,
|
|
1893
|
+
locale: MENU_NS,
|
|
1894
|
+
inject: (sessionId) => {
|
|
1895
|
+
const actx = sessions.scope(sessionId);
|
|
1896
|
+
if (actx === void 0) throw new Error(`ui-input-trigger: session "${String(sessionId)}" resolved no scope`);
|
|
1897
|
+
const controller = inputTriggers.sessionOf(actx);
|
|
1898
|
+
return {
|
|
1899
|
+
menu: controller.menu,
|
|
1900
|
+
headers: controller.headers,
|
|
1901
|
+
onPick: (source, index, action) => {
|
|
1902
|
+
controller.pick(source, index, action);
|
|
1903
|
+
},
|
|
1904
|
+
onCrumb: (source, index) => {
|
|
1905
|
+
controller.pickCrumb(source, index);
|
|
1906
|
+
},
|
|
1907
|
+
onHover: (source, index) => {
|
|
1908
|
+
controller.hover(source, index);
|
|
1909
|
+
},
|
|
1910
|
+
onDismiss: () => {
|
|
1911
|
+
controller.dismiss();
|
|
1912
|
+
}
|
|
1913
|
+
};
|
|
1914
|
+
}
|
|
1915
|
+
}, MenuView));
|
|
1916
|
+
});
|
|
1917
|
+
}
|
|
1918
|
+
//#endregion
|
|
1919
|
+
exports.InputTriggerController = InputTriggerController;
|
|
1920
|
+
exports.InputTriggerService = InputTriggerService;
|
|
1921
|
+
exports.apply = apply;
|
|
1922
|
+
exports.inject = inject;
|
|
1923
|
+
return module.exports;
|
|
1924
|
+
}
|
|
1925
|
+
});
|
|
1926
|
+
|
|
1927
|
+
//# sourceMappingURL=client.js.map
|