@pi-unipi/command-enchantment 0.1.7 → 0.1.9
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/package.json +6 -2
- package/src/__tests__/provider.sorting.test.ts +423 -0
- package/src/constants.ts +20 -0
- package/src/provider.ts +16 -3
- package/src/settings.ts +2 -2
- package/src/sorting.ts +81 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-unipi/command-enchantment",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Enhanced TUI autocomplete for /unipi:* commands — colored, sorted, and grouped by package",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,7 +34,11 @@
|
|
|
34
34
|
"@mariozechner/pi-coding-agent": "*",
|
|
35
35
|
"@mariozechner/pi-tui": "*"
|
|
36
36
|
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "vitest run"
|
|
39
|
+
},
|
|
37
40
|
"devDependencies": {
|
|
38
|
-
"@types/node": "^25.6.0"
|
|
41
|
+
"@types/node": "^25.6.0",
|
|
42
|
+
"vitest": "^4.1.5"
|
|
39
43
|
}
|
|
40
44
|
}
|
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the 4-tier autocomplete command sorting model.
|
|
3
|
+
*
|
|
4
|
+
* Exercises crossItemPriority() and sortTaggedItems() from sorting.ts.
|
|
5
|
+
* These are pure functions extracted from the provider closure for testability.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { describe, it, expect } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
crossItemPriority,
|
|
11
|
+
sortTaggedItems,
|
|
12
|
+
type TaggedItem,
|
|
13
|
+
} from "../sorting.js";
|
|
14
|
+
import type { AutocompleteItem } from "@mariozechner/pi-tui";
|
|
15
|
+
|
|
16
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Shorthand to create an AutocompleteItem. */
|
|
19
|
+
function item(value: string, label?: string): AutocompleteItem {
|
|
20
|
+
return { value, label: label ?? value, description: "" };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Shorthand to create a TaggedItem. */
|
|
24
|
+
function tagged(value: string, isUnipi: boolean, label?: string): TaggedItem {
|
|
25
|
+
return { item: item(value, label), isUnipi };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Extract just the values from a sorted TaggedItem[] for easy assertions. */
|
|
29
|
+
function values(sorted: TaggedItem[]): string[] {
|
|
30
|
+
return sorted.map((t) => t.item.value);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
34
|
+
// crossItemPriority — tier assignment
|
|
35
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
describe("crossItemPriority", () => {
|
|
38
|
+
// ── Tier 0: exact full-value match ──────────────────────────────────
|
|
39
|
+
|
|
40
|
+
it("assigns tier 0 for exact base command match", () => {
|
|
41
|
+
// query "new" matches item value "new" exactly
|
|
42
|
+
expect(crossItemPriority(item("new"), false, "new")).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("assigns tier 0 for exact unipi full-value match", () => {
|
|
46
|
+
// query "unipi:brainstorm" matches item value "unipi:brainstorm"
|
|
47
|
+
expect(crossItemPriority(item("unipi:brainstorm"), true, "unipi:brainstorm")).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("does NOT assign tier 0 for short-name-only match on unipi item", () => {
|
|
51
|
+
// query "brainstorm" is not the full value "unipi:brainstorm"
|
|
52
|
+
expect(crossItemPriority(item("unipi:brainstorm"), true, "brainstorm")).not.toBe(0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ── Tier 1: unipi short-name exact match ────────────────────────────
|
|
56
|
+
|
|
57
|
+
it("assigns tier 1 when unipi short name matches query exactly", () => {
|
|
58
|
+
expect(crossItemPriority(item("unipi:brainstorm"), true, "brainstorm")).toBe(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("does NOT assign tier 1 for non-unipi items", () => {
|
|
62
|
+
// "new" is not a unipi item, so short === full === "new", which is tier 0 (exact)
|
|
63
|
+
expect(crossItemPriority(item("new"), false, "new")).toBe(0);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("does NOT assign tier 1 when unipi short name doesn't match", () => {
|
|
67
|
+
expect(crossItemPriority(item("unipi:brainstorm"), true, "brain")).not.toBeLessThanOrEqual(1);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Tier 2: prefix match ────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
it("assigns tier 2 for base prefix match", () => {
|
|
73
|
+
expect(crossItemPriority(item("work"), false, "wor")).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("assigns tier 2 for unipi short-name prefix match", () => {
|
|
77
|
+
// short = "work", query = "wor" → startsWith → tier 2
|
|
78
|
+
expect(crossItemPriority(item("unipi:work"), true, "wor")).toBe(2);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("assigns tier 2 when full value has prefix (unipi: prefix)", () => {
|
|
82
|
+
// full = "unipi:compact", query = "unipi:co" → startsWith → tier 2
|
|
83
|
+
expect(crossItemPriority(item("unipi:compact"), true, "unipi:co")).toBe(2);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── Tier 3: fuzzy fallback ──────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
it("assigns tier 3 for fuzzy-only match", () => {
|
|
89
|
+
// "review-work" doesn't start with "wor" but contains characters
|
|
90
|
+
expect(crossItemPriority(item("unipi:review-work"), true, "wor")).toBe(3);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("assigns tier 3 for character subsequence match", () => {
|
|
94
|
+
// "fix" is a subsequence of... no wait, let's use "fx" vs "fix"
|
|
95
|
+
// "fix" contains f, i, x. query "fx" → f...x → subsequence → tier 3
|
|
96
|
+
expect(crossItemPriority(item("fix"), false, "fx")).toBe(3);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("assigns tier 3 for non-matching query (all items get tier 3)", () => {
|
|
100
|
+
expect(crossItemPriority(item("brainstorm"), false, "xyz")).toBe(3);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ── Case insensitivity ──────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
it("matches case-insensitively (tier 0)", () => {
|
|
106
|
+
expect(crossItemPriority(item("new"), false, "New")).toBe(0);
|
|
107
|
+
expect(crossItemPriority(item("New"), false, "new")).toBe(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("matches case-insensitively (tier 2)", () => {
|
|
111
|
+
expect(crossItemPriority(item("Compact"), false, "co")).toBe(2);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("matches case-insensitively (tier 1)", () => {
|
|
115
|
+
expect(crossItemPriority(item("unipi:Brainstorm"), true, "brainstorm")).toBe(1);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
120
|
+
// sortTaggedItems — cross-group merge sort
|
|
121
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
122
|
+
|
|
123
|
+
describe("sortTaggedItems", () => {
|
|
124
|
+
// ── Test 1: Exact base command before fuzzy unipi ───────────────────
|
|
125
|
+
|
|
126
|
+
it("ranks exact base command (tier 0) before fuzzy unipi (tier 3)", () => {
|
|
127
|
+
const items = [
|
|
128
|
+
tagged("unipi:review-work", true), // tier 3 (r→e→w fuzzy)
|
|
129
|
+
tagged("new", false), // tier 0 (exact)
|
|
130
|
+
];
|
|
131
|
+
|
|
132
|
+
const sorted = sortTaggedItems(items, "new");
|
|
133
|
+
expect(values(sorted)).toEqual(["new", "unipi:review-work"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── Test 2: Unipi short-name exact (tier 1) ────────────────────────
|
|
137
|
+
|
|
138
|
+
it("ranks unipi short-name exact (tier 1) above prefix and fuzzy", () => {
|
|
139
|
+
const items = [
|
|
140
|
+
tagged("unipi:brainstorm", true), // tier 1
|
|
141
|
+
tagged("unipi:plan", true), // tier 3 (no b-r-a-i-n)
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
const sorted = sortTaggedItems(items, "brainstorm");
|
|
145
|
+
expect(sorted[0].item.value).toBe("unipi:brainstorm");
|
|
146
|
+
expect(crossItemPriority(items[0].item, true, "brainstorm")).toBe(1);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ── Test 3: Full unipi exact (tier 0) ──────────────────────────────
|
|
150
|
+
|
|
151
|
+
it("ranks full unipi: value exact match as tier 0", () => {
|
|
152
|
+
const items = [
|
|
153
|
+
tagged("unipi:brainstorm", true),
|
|
154
|
+
tagged("brainstorm", false), // also tier 0 exact on value
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
const sorted = sortTaggedItems(items, "unipi:brainstorm");
|
|
158
|
+
// Both could be tier 0 or fuzzy. unipi:brainstorm is tier 0 (exact full).
|
|
159
|
+
// "brainstorm" as base: full "brainstorm" !== "unipi:brainstorm" → not tier 0
|
|
160
|
+
// full.startsWith query? "brainstorm".startsWith("unipi:brainstorm") → no
|
|
161
|
+
// So "brainstorm" gets tier 3
|
|
162
|
+
expect(sorted[0].item.value).toBe("unipi:brainstorm");
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// ── Test 4: Prefix match ordering (non-unipi before unipi) ─────────
|
|
166
|
+
|
|
167
|
+
it("within same tier, sorts non-unipi before unipi", () => {
|
|
168
|
+
const items = [
|
|
169
|
+
tagged("unipi:work", true), // tier 2 (prefix "wor" → "work")
|
|
170
|
+
tagged("work", false), // tier 2 (prefix "wor" → "work")
|
|
171
|
+
];
|
|
172
|
+
|
|
173
|
+
const sorted = sortTaggedItems(items, "wor");
|
|
174
|
+
expect(values(sorted)).toEqual(["work", "unipi:work"]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ── Test 5: Fuzzy sorted by similarity (shorter name first) ────────
|
|
178
|
+
|
|
179
|
+
it("within tier 3 fuzzy, shorter names sort first", () => {
|
|
180
|
+
const items = [
|
|
181
|
+
tagged("unipi:review-work", true), // longer
|
|
182
|
+
tagged("fix", false), // shorter
|
|
183
|
+
tagged("unipi:fix", true), // medium
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
const sorted = sortTaggedItems(items, "fx");
|
|
187
|
+
// All tier 3 fuzzy. Sorted by: non-unipi first, then by length.
|
|
188
|
+
// "fix" (len 3, non-unipi) → "unipi:fix" (len 9, unipi) → "unipi:review-work" (len 17, unipi)
|
|
189
|
+
expect(values(sorted)).toEqual(["fix", "unipi:fix", "unipi:review-work"]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// ── Test 6: Mixed tiers ────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
it("sorts across mixed tiers: exact → prefix → fuzzy", () => {
|
|
195
|
+
const items = [
|
|
196
|
+
tagged("unipi:compact", true), // tier 2 (prefix "co")
|
|
197
|
+
tagged("compact-xyz", false), // tier 2 (prefix "co")
|
|
198
|
+
tagged("co", false), // tier 0 (exact "co")
|
|
199
|
+
tagged("unipi:consultant", true), // tier 2 (prefix "co")
|
|
200
|
+
tagged("config", false), // tier 2 (prefix "co")
|
|
201
|
+
tagged("unipi:chore-create", true), // tier 3 (fuzzy c→o)
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const sorted = sortTaggedItems(items, "co");
|
|
205
|
+
// Tier 0: co
|
|
206
|
+
// Tier 2: compact-xyz, config (non-unipi) then unipi:compact, unipi:consultant (unipi)
|
|
207
|
+
// Tier 3: unipi:chore-create
|
|
208
|
+
const result = values(sorted);
|
|
209
|
+
expect(result[0]).toBe("co");
|
|
210
|
+
// Tier 2 non-unipi
|
|
211
|
+
expect(result.slice(1, 3)).toEqual(expect.arrayContaining(["compact-xyz", "config"]));
|
|
212
|
+
// Tier 2 unipi
|
|
213
|
+
expect(result.slice(3, 5)).toEqual(expect.arrayContaining(["unipi:compact", "unipi:consultant"]));
|
|
214
|
+
// Tier 3
|
|
215
|
+
expect(result[5]).toBe("unipi:chore-create");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
// ── Test 7: No base command match, only unipi ──────────────────────
|
|
219
|
+
|
|
220
|
+
it("handles unipi-only matches (tier 1 short-name exact)", () => {
|
|
221
|
+
const items = [
|
|
222
|
+
tagged("unipi:ralph", true), // tier 1 (short "ralph" === "ralph")
|
|
223
|
+
tagged("unipi:ralph-stop", true), // tier 2 (prefix "ralph")
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const sorted = sortTaggedItems(items, "ralph");
|
|
227
|
+
expect(values(sorted)).toEqual(["unipi:ralph", "unipi:ralph-stop"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// ── Test 8: Skill commands filtered (simulated: skill: items present) ─
|
|
231
|
+
|
|
232
|
+
it("sortItems does not filter skill commands — that's the provider's job", () => {
|
|
233
|
+
// The sorting function itself is pure — it just sorts whatever it receives.
|
|
234
|
+
// Skill filtering happens at the provider level. Verify sorting handles them.
|
|
235
|
+
// Note: "skill:animate" does NOT start with "a", so it gets tier 3 (fuzzy).
|
|
236
|
+
// "unipi:auto" short name "auto" starts with "a" → tier 2 (prefix).
|
|
237
|
+
const items = [
|
|
238
|
+
tagged("skill:animate", false),
|
|
239
|
+
tagged("skill:audit", false),
|
|
240
|
+
tagged("unipi:auto", true),
|
|
241
|
+
];
|
|
242
|
+
|
|
243
|
+
const sorted = sortTaggedItems(items, "a");
|
|
244
|
+
// unipi:auto (tier 2) before skill: items (tier 3)
|
|
245
|
+
// Within tier 3: non-unipi, sorted by length: audit(11) < animate(13)
|
|
246
|
+
expect(values(sorted)).toEqual(["unipi:auto", "skill:audit", "skill:animate"]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ── Test 9: Skill query scenario ───────────────────────────────────
|
|
250
|
+
|
|
251
|
+
it("when skill: prefix items are present, they sort by tier like any other", () => {
|
|
252
|
+
// "skill:work" does NOT start with "work" → tier 3 (fuzzy)
|
|
253
|
+
// "unipi:work" short name "work" starts with "work" → tier 2 (prefix)
|
|
254
|
+
const items = [
|
|
255
|
+
tagged("skill:work", false), // tier 3 (fuzzy)
|
|
256
|
+
tagged("unipi:work", true), // tier 2 (prefix)
|
|
257
|
+
tagged("skill:workshop", false), // tier 3 (fuzzy)
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
const sorted = sortTaggedItems(items, "work");
|
|
261
|
+
// unipi:work (tier 2) before skill items (tier 3)
|
|
262
|
+
// Within tier 3: non-unipi, sorted by length: skill:work(11) < skill:workshop(15)
|
|
263
|
+
expect(values(sorted)).toEqual(["unipi:work", "skill:work", "skill:workshop"]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
// ── Test 10: Empty query ───────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
it("empty query gives all items tier 0 (exact on empty string)", () => {
|
|
269
|
+
// When query is "", every value matches exactly: "".startsWith("") → true
|
|
270
|
+
// Actually: full === "" is false for non-empty values. But "".startsWith("") is true → tier 2
|
|
271
|
+
// Wait: full === q where q = "". If value is "new", full="new" !== "" → not tier 0.
|
|
272
|
+
// short.startsWith("") → true → tier 2.
|
|
273
|
+
// So all items get tier 2 when query is "".
|
|
274
|
+
// Within tier 2: non-unipi first, then stable.
|
|
275
|
+
const items = [
|
|
276
|
+
tagged("unipi:brain", true),
|
|
277
|
+
tagged("new", false),
|
|
278
|
+
tagged("unipi:work", true),
|
|
279
|
+
tagged("fix", false),
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
const sorted = sortTaggedItems(items, "");
|
|
283
|
+
// All tier 2. Non-unipi first (preserving original order), then unipi.
|
|
284
|
+
expect(values(sorted)).toEqual(["new", "fix", "unipi:brain", "unipi:work"]);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── Test 11: Case insensitive ──────────────────────────────────────
|
|
288
|
+
|
|
289
|
+
it("matches case-insensitively across tiers", () => {
|
|
290
|
+
const items = [
|
|
291
|
+
tagged("unipi:Brainstorm", true),
|
|
292
|
+
tagged("New", false),
|
|
293
|
+
];
|
|
294
|
+
|
|
295
|
+
// query "/New" stripped → "new"
|
|
296
|
+
const sorted = sortTaggedItems(items, "new");
|
|
297
|
+
expect(sorted[0].item.value).toBe("New"); // tier 0 exact (case-insensitive)
|
|
298
|
+
|
|
299
|
+
// query "BRAINSTORM" → unipi short-name "brainstorm" exact (case-insensitive)
|
|
300
|
+
const sorted2 = sortTaggedItems(items, "BRAINSTORM");
|
|
301
|
+
expect(sorted2[0].item.value).toBe("unipi:Brainstorm"); // tier 1
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── Test 12: Stable sort ───────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
it("preserves original order for items at same tier and same source", () => {
|
|
307
|
+
const items = [
|
|
308
|
+
tagged("alpha", false), // tier 2 (prefix "a")
|
|
309
|
+
tagged("unipi:a-first", true), // tier 2
|
|
310
|
+
tagged("beta", false), // tier 2
|
|
311
|
+
tagged("unipi:a-second", true), // tier 2
|
|
312
|
+
tagged("gamma", false), // tier 2
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
const sorted = sortTaggedItems(items, "a");
|
|
316
|
+
const result = values(sorted);
|
|
317
|
+
|
|
318
|
+
// Non-unipi items preserve their original relative order: alpha, beta, gamma
|
|
319
|
+
const nonUnipi = result.filter((v) => !v.startsWith("unipi:"));
|
|
320
|
+
expect(nonUnipi).toEqual(["alpha", "beta", "gamma"]);
|
|
321
|
+
|
|
322
|
+
// Unipi items preserve their original relative order: a-first, a-second
|
|
323
|
+
const unipiItems = result.filter((v) => v.startsWith("unipi:"));
|
|
324
|
+
expect(unipiItems).toEqual(["unipi:a-first", "unipi:a-second"]);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// ── Test 13: btw:new scenario ──────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
it("btw:new gets tier 3 fuzzy for query 'new' (not prefix match)", () => {
|
|
330
|
+
// "btw:new" does NOT start with "new" → not tier 2
|
|
331
|
+
// "btw:new" !== "new" → not tier 0
|
|
332
|
+
// fuzzy match: b...t...w...:...n...e...w → "new" is subsequence → tier 3
|
|
333
|
+
const items = [
|
|
334
|
+
tagged("btw:new", false), // tier 3
|
|
335
|
+
tagged("new", false), // tier 0
|
|
336
|
+
tagged("unipi:new-thing", true), // tier 2 (prefix "new")
|
|
337
|
+
];
|
|
338
|
+
|
|
339
|
+
const sorted = sortTaggedItems(items, "new");
|
|
340
|
+
expect(values(sorted)).toEqual(["new", "unipi:new-thing", "btw:new"]);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("verifies btw:new is tier 3 for query 'new'", () => {
|
|
344
|
+
expect(crossItemPriority(item("btw:new"), false, "new")).toBe(3);
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
it("verifies 'new' is tier 0 for query 'new'", () => {
|
|
348
|
+
expect(crossItemPriority(item("new"), false, "new")).toBe(0);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
353
|
+
// Additional edge cases
|
|
354
|
+
// ═══════════════════════════════════════════════════════════════════════
|
|
355
|
+
|
|
356
|
+
describe("edge cases", () => {
|
|
357
|
+
it("handles single item", () => {
|
|
358
|
+
const items = [tagged("new", false)];
|
|
359
|
+
const sorted = sortTaggedItems(items, "new");
|
|
360
|
+
expect(values(sorted)).toEqual(["new"]);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("handles empty input array", () => {
|
|
364
|
+
const sorted = sortTaggedItems([], "new");
|
|
365
|
+
expect(sorted).toEqual([]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("does not mutate the original array", () => {
|
|
369
|
+
const original = [tagged("b", false), tagged("a", false)];
|
|
370
|
+
const copy = [...original];
|
|
371
|
+
sortTaggedItems(original, "a");
|
|
372
|
+
expect(original).toEqual(copy);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("sorts multiple tier 0 exact matches (non-unipi before unipi)", () => {
|
|
376
|
+
const items = [
|
|
377
|
+
tagged("unipi:abc", true), // tier 0 (full value "unipi:abc" === query)
|
|
378
|
+
tagged("unipi:abc", false), // tier 0 (but isUnipi=false, value doesn't start with "unipi:")
|
|
379
|
+
];
|
|
380
|
+
|
|
381
|
+
// Both have value "unipi:abc", but one is tagged isUnipi=true
|
|
382
|
+
// Wait: the non-unipi one has value "unipi:abc" — that's unusual but possible
|
|
383
|
+
// crossItemPriority: full="unipi:abc" === "unipi:abc" → tier 0 for both
|
|
384
|
+
// Same tier → non-unipi first
|
|
385
|
+
const sorted = sortTaggedItems(items, "unipi:abc");
|
|
386
|
+
expect(sorted[0].isUnipi).toBe(false);
|
|
387
|
+
expect(sorted[1].isUnipi).toBe(true);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("tier 3 sorts by length, non-unipi first within same length", () => {
|
|
391
|
+
const items = [
|
|
392
|
+
tagged("unipi:ab", true), // len 9, tier 3
|
|
393
|
+
tagged("cd", false), // len 2, tier 3
|
|
394
|
+
tagged("ef", false), // len 2, tier 3
|
|
395
|
+
tagged("unipi:gh", true), // len 9, tier 3
|
|
396
|
+
];
|
|
397
|
+
|
|
398
|
+
const sorted = sortTaggedItems(items, "z");
|
|
399
|
+
// All tier 3. Non-unipi first (by length within non-unipi): cd(2), ef(2)
|
|
400
|
+
// Then unipi (by length): unipi:ab(9), unipi:gh(9)
|
|
401
|
+
const result = values(sorted);
|
|
402
|
+
expect(result.slice(0, 2)).toEqual(["cd", "ef"]);
|
|
403
|
+
expect(result.slice(2, 4)).toEqual(["unipi:ab", "unipi:gh"]);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("handles query matching full unipi prefix + partial short name", () => {
|
|
407
|
+
// query "unipi:bra" → full "unipi:brainstorm".startsWith("unipi:bra") → tier 2
|
|
408
|
+
expect(crossItemPriority(item("unipi:brainstorm"), true, "unipi:bra")).toBe(2);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("unipi item with tier 1 beats base item with tier 2", () => {
|
|
412
|
+
const items = [
|
|
413
|
+
tagged("work", false), // tier 2 (prefix "brain"... no)
|
|
414
|
+
tagged("unipi:brainstorm", true), // tier 1 (short exact)
|
|
415
|
+
];
|
|
416
|
+
|
|
417
|
+
// query "brainstorm":
|
|
418
|
+
// "work" → full "work" !== "brainstorm", !startsWith → tier 3
|
|
419
|
+
// "unipi:brainstorm" → short "brainstorm" === "brainstorm" → tier 1
|
|
420
|
+
const sorted = sortTaggedItems(items, "brainstorm");
|
|
421
|
+
expect(values(sorted)).toEqual(["unipi:brainstorm", "work"]);
|
|
422
|
+
});
|
|
423
|
+
});
|
package/src/constants.ts
CHANGED
|
@@ -30,6 +30,8 @@ export const PACKAGE_ORDER: string[] = [
|
|
|
30
30
|
"notify",
|
|
31
31
|
"kanboard",
|
|
32
32
|
"footer",
|
|
33
|
+
"updater",
|
|
34
|
+
"input-shortcuts",
|
|
33
35
|
];
|
|
34
36
|
|
|
35
37
|
// ─── Package Colors ──────────────────────────────────────────────────
|
|
@@ -48,6 +50,8 @@ export const PACKAGE_COLORS: Record<string, string> = {
|
|
|
48
50
|
notify: `${ESC}[96m`, // Bright Cyan
|
|
49
51
|
kanboard: `${ESC}[92m`, // Bright Green
|
|
50
52
|
footer: `${ESC}[34m`, // Blue
|
|
53
|
+
updater: `${ESC}[93m`, // Bright Yellow
|
|
54
|
+
"input-shortcuts": `${ESC}[95m`, // Bright Magenta
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
// ─── Command Registry ────────────────────────────────────────────────
|
|
@@ -149,6 +153,14 @@ export const COMMAND_REGISTRY: Record<string, string> = {
|
|
|
149
153
|
// footer (2 commands)
|
|
150
154
|
"unipi:footer": "footer",
|
|
151
155
|
"unipi:footer-settings": "footer",
|
|
156
|
+
|
|
157
|
+
// updater (3 commands)
|
|
158
|
+
"unipi:readme": "updater",
|
|
159
|
+
"unipi:changelog": "updater",
|
|
160
|
+
"unipi:updater-settings": "updater",
|
|
161
|
+
|
|
162
|
+
// input-shortcuts (1 command)
|
|
163
|
+
"unipi:stash-settings": "input-shortcuts",
|
|
152
164
|
};
|
|
153
165
|
|
|
154
166
|
// ─── Description Map ─────────────────────────────────────────────────
|
|
@@ -236,6 +248,12 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
236
248
|
|
|
237
249
|
"unipi:footer": "Toggle footer or switch preset",
|
|
238
250
|
"unipi:footer-settings": "Open footer settings — toggle groups and segments",
|
|
251
|
+
|
|
252
|
+
"unipi:readme": "Browse package README files",
|
|
253
|
+
"unipi:changelog": "Browse changelog (Keep a Changelog format)",
|
|
254
|
+
"unipi:updater-settings": "Configure updater — check interval and auto-update",
|
|
255
|
+
|
|
256
|
+
"unipi:stash-settings": "Open input shortcuts settings — customize keybindings",
|
|
239
257
|
};
|
|
240
258
|
|
|
241
259
|
// ─── Package Display Names ───────────────────────────────────────────
|
|
@@ -254,4 +272,6 @@ export const PACKAGE_LABELS: Record<string, string> = {
|
|
|
254
272
|
notify: "notify",
|
|
255
273
|
kanboard: "kanboard",
|
|
256
274
|
footer: "footer",
|
|
275
|
+
updater: "updater",
|
|
276
|
+
"input-shortcuts": "input-shortcuts",
|
|
257
277
|
};
|
package/src/provider.ts
CHANGED
|
@@ -21,6 +21,11 @@ import {
|
|
|
21
21
|
PACKAGE_ORDER,
|
|
22
22
|
colorize,
|
|
23
23
|
} from "./constants.js";
|
|
24
|
+
import {
|
|
25
|
+
crossItemPriority,
|
|
26
|
+
sortTaggedItems,
|
|
27
|
+
} from "./sorting.js";
|
|
28
|
+
import type { TaggedItem } from "./sorting.js";
|
|
24
29
|
|
|
25
30
|
// ─── Fuzzy matching ──────────────────────────────────────────────────
|
|
26
31
|
|
|
@@ -308,6 +313,9 @@ export function createEnchantedProvider(
|
|
|
308
313
|
// Check if user explicitly typed /skill: prefix
|
|
309
314
|
const isExplicitSkillQuery = effectivePrefix.replace(/^\//, "").toLowerCase().startsWith("skill:");
|
|
310
315
|
|
|
316
|
+
// Extract the query for cross-group match quality scoring
|
|
317
|
+
const finalQuery = effectivePrefix.replace(/^\//, "").toLowerCase();
|
|
318
|
+
|
|
311
319
|
// Build final list based on query context
|
|
312
320
|
let finalItems: AutocompleteItem[];
|
|
313
321
|
|
|
@@ -315,9 +323,14 @@ export function createEnchantedProvider(
|
|
|
315
323
|
// User explicitly wants skill commands — show them first
|
|
316
324
|
finalItems = [...skillItems, ...enhancedUnipiItems, ...systemItems];
|
|
317
325
|
} else {
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
326
|
+
// Merge unipi + system items, sorted by 4-tier quality then source
|
|
327
|
+
const tagged: TaggedItem[] = [
|
|
328
|
+
...systemItems.map((item) => ({ item, isUnipi: false })),
|
|
329
|
+
...enhancedUnipiItems.map((item) => ({ item, isUnipi: true })),
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
const sorted = sortTaggedItems(tagged, finalQuery);
|
|
333
|
+
finalItems = sorted.map((t) => t.item);
|
|
321
334
|
}
|
|
322
335
|
|
|
323
336
|
return {
|
package/src/settings.ts
CHANGED
|
@@ -52,8 +52,8 @@ export function loadConfig(): CommandEnchantmentConfig {
|
|
|
52
52
|
...config,
|
|
53
53
|
};
|
|
54
54
|
}
|
|
55
|
-
} catch
|
|
56
|
-
|
|
55
|
+
} catch {
|
|
56
|
+
// Silently ignore — config load failure falls back to defaults.
|
|
57
57
|
}
|
|
58
58
|
return DEFAULT_CONFIG;
|
|
59
59
|
}
|
package/src/sorting.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @pi-unipi/command-enchantment — Sorting Logic
|
|
3
|
+
*
|
|
4
|
+
* Extracted cross-item priority and merge-sort for testability.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { AutocompleteItem } from "@mariozechner/pi-tui";
|
|
8
|
+
|
|
9
|
+
// ─── Types ───────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
/** Tagged item carrying its source group for sorting. */
|
|
12
|
+
export interface TaggedItem {
|
|
13
|
+
item: AutocompleteItem;
|
|
14
|
+
isUnipi: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// ─── 4-tier priority ─────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute the match-quality tier for an autocomplete item.
|
|
21
|
+
*
|
|
22
|
+
* Tier 0 — Base command exact match: full `item.value` equals the query.
|
|
23
|
+
* Also catches `unipi:abc` when user typed `unipi:abc`.
|
|
24
|
+
* Tier 1 — Unipi short-name exact match: query `brainstorm` →
|
|
25
|
+
* `unipi:brainstorm` (text after `unipi:` matches exactly).
|
|
26
|
+
* Tier 2 — Prefix match: command name starts with the query.
|
|
27
|
+
* Tier 3 — Fuzzy match: character subsequence.
|
|
28
|
+
*/
|
|
29
|
+
export function crossItemPriority(
|
|
30
|
+
item: AutocompleteItem,
|
|
31
|
+
isUnipi: boolean,
|
|
32
|
+
query: string,
|
|
33
|
+
): number {
|
|
34
|
+
const full = item.value.toLowerCase();
|
|
35
|
+
const short = isUnipi
|
|
36
|
+
? item.value.replace("unipi:", "").toLowerCase()
|
|
37
|
+
: full;
|
|
38
|
+
|
|
39
|
+
const q = query.toLowerCase();
|
|
40
|
+
|
|
41
|
+
// Tier 0: exact full-value match
|
|
42
|
+
if (full === q) return 0;
|
|
43
|
+
// Tier 1: unipi short-name exact match
|
|
44
|
+
if (isUnipi && short === q) return 1;
|
|
45
|
+
// Tier 2: prefix match
|
|
46
|
+
if (short.startsWith(q) || full.startsWith(q)) return 2;
|
|
47
|
+
// Tier 3: fuzzy
|
|
48
|
+
return 3;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ─── Cross-group merge sort ─────────────────────────────────────────
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Sort a mixed list of system + unipi items using the 4-tier model.
|
|
55
|
+
*
|
|
56
|
+
* Within the same tier:
|
|
57
|
+
* - non-unipi commands sort before unipi commands
|
|
58
|
+
* - Tier 3 fuzzy: shorter name = closer match
|
|
59
|
+
* - Stable sort preserves original order for ties
|
|
60
|
+
*/
|
|
61
|
+
export function sortTaggedItems(items: TaggedItem[], query: string): TaggedItem[] {
|
|
62
|
+
const copy = [...items];
|
|
63
|
+
copy.sort((a, b) => {
|
|
64
|
+
const priA = crossItemPriority(a.item, a.isUnipi, query);
|
|
65
|
+
const priB = crossItemPriority(b.item, b.isUnipi, query);
|
|
66
|
+
if (priA !== priB) return priA - priB;
|
|
67
|
+
|
|
68
|
+
// Same tier: non-unipi first
|
|
69
|
+
if (a.isUnipi !== b.isUnipi) return a.isUnipi ? 1 : -1;
|
|
70
|
+
|
|
71
|
+
// Tier 3 (fuzzy): sort by similarity — shorter name = closer match
|
|
72
|
+
if (priA === 3) {
|
|
73
|
+
const lenA = a.item.value.length;
|
|
74
|
+
const lenB = b.item.value.length;
|
|
75
|
+
if (lenA !== lenB) return lenA - lenB;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return 0; // preserve original order (stable sort)
|
|
79
|
+
});
|
|
80
|
+
return copy;
|
|
81
|
+
}
|