@pi-unipi/command-enchantment 0.1.8 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/src/__tests__/provider.sorting.test.ts +423 -0
- package/src/constants.ts +28 -14
- package/src/provider.ts +16 -3
- 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.
|
|
3
|
+
"version": "2.0.0",
|
|
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
|
@@ -32,6 +32,7 @@ export const PACKAGE_ORDER: string[] = [
|
|
|
32
32
|
"footer",
|
|
33
33
|
"updater",
|
|
34
34
|
"input-shortcuts",
|
|
35
|
+
"cocoindex",
|
|
35
36
|
];
|
|
36
37
|
|
|
37
38
|
// ─── Package Colors ──────────────────────────────────────────────────
|
|
@@ -52,10 +53,11 @@ export const PACKAGE_COLORS: Record<string, string> = {
|
|
|
52
53
|
footer: `${ESC}[34m`, // Blue
|
|
53
54
|
updater: `${ESC}[93m`, // Bright Yellow
|
|
54
55
|
"input-shortcuts": `${ESC}[95m`, // Bright Magenta
|
|
56
|
+
cocoindex: `${ESC}[97m`, // Bright White
|
|
55
57
|
};
|
|
56
58
|
|
|
57
59
|
// ─── Command Registry ────────────────────────────────────────────────
|
|
58
|
-
/** Mapping of full command name → package name (
|
|
60
|
+
/** Mapping of full command name → package name (80 verified commands) */
|
|
59
61
|
export const COMMAND_REGISTRY: Record<string, string> = {
|
|
60
62
|
// workflow (20 commands)
|
|
61
63
|
"unipi:brainstorm": "workflow",
|
|
@@ -79,8 +81,9 @@ export const COMMAND_REGISTRY: Record<string, string> = {
|
|
|
79
81
|
"unipi:chore-create": "workflow",
|
|
80
82
|
"unipi:chore-execute": "workflow",
|
|
81
83
|
|
|
82
|
-
// ralph (
|
|
84
|
+
// ralph (3 commands)
|
|
83
85
|
"unipi:ralph": "ralph",
|
|
86
|
+
"unipi:ralph-start": "ralph",
|
|
84
87
|
"unipi:ralph-stop": "ralph",
|
|
85
88
|
|
|
86
89
|
// memory (7 commands)
|
|
@@ -99,7 +102,7 @@ export const COMMAND_REGISTRY: Record<string, string> = {
|
|
|
99
102
|
"unipi:mcp-settings": "mcp",
|
|
100
103
|
"unipi:mcp-reload": "mcp",
|
|
101
104
|
|
|
102
|
-
// utility (
|
|
105
|
+
// utility (11 commands)
|
|
103
106
|
"unipi:continue": "utility",
|
|
104
107
|
"unipi:reload": "utility",
|
|
105
108
|
"unipi:status": "utility",
|
|
@@ -123,16 +126,21 @@ export const COMMAND_REGISTRY: Record<string, string> = {
|
|
|
123
126
|
"unipi:web-settings": "web-api",
|
|
124
127
|
"unipi:web-cache-clear": "web-api",
|
|
125
128
|
|
|
126
|
-
// compact (
|
|
129
|
+
// compact (7 commands)
|
|
130
|
+
"unipi:lossless-compact": "compact",
|
|
127
131
|
"unipi:compact": "compact",
|
|
128
132
|
"unipi:compact-recall": "compact",
|
|
129
133
|
"unipi:compact-stats": "compact",
|
|
130
134
|
"unipi:compact-doctor": "compact",
|
|
131
135
|
"unipi:compact-settings": "compact",
|
|
132
136
|
"unipi:compact-preset": "compact",
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
"unipi:
|
|
137
|
+
|
|
138
|
+
// cocoindex (5 commands)
|
|
139
|
+
"unipi:cocoindex-update": "cocoindex",
|
|
140
|
+
"unipi:cocoindex-status": "cocoindex",
|
|
141
|
+
"unipi:cocoindex-init": "cocoindex",
|
|
142
|
+
"unipi:cocoindex-search": "cocoindex",
|
|
143
|
+
"unipi:cocoindex-settings": "cocoindex",
|
|
136
144
|
|
|
137
145
|
// milestone (2 commands)
|
|
138
146
|
"unipi:milestone-onboard": "milestone",
|
|
@@ -146,13 +154,14 @@ export const COMMAND_REGISTRY: Record<string, string> = {
|
|
|
146
154
|
"unipi:notify-test": "notify",
|
|
147
155
|
"unipi:notify-recap-model": "notify",
|
|
148
156
|
|
|
149
|
-
// kanboard (
|
|
157
|
+
// kanboard (2 commands)
|
|
150
158
|
"unipi:kanboard": "kanboard",
|
|
151
159
|
"unipi:kanboard-doctor": "kanboard",
|
|
152
160
|
|
|
153
|
-
// footer (
|
|
161
|
+
// footer (3 commands)
|
|
154
162
|
"unipi:footer": "footer",
|
|
155
163
|
"unipi:footer-settings": "footer",
|
|
164
|
+
"unipi:footer-help": "footer",
|
|
156
165
|
|
|
157
166
|
// updater (3 commands)
|
|
158
167
|
"unipi:readme": "updater",
|
|
@@ -187,7 +196,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
187
196
|
"unipi:chore-create": "Create reusable chore definition",
|
|
188
197
|
"unipi:chore-execute": "Execute a saved chore",
|
|
189
198
|
|
|
190
|
-
"unipi:ralph": "Ralph loop — start/resume
|
|
199
|
+
"unipi:ralph": "Ralph loop — start/resume/status commands",
|
|
200
|
+
"unipi:ralph-start": "Start a ralph loop for the current task",
|
|
191
201
|
"unipi:ralph-stop": "Stop the active ralph loop",
|
|
192
202
|
|
|
193
203
|
"unipi:memory-process": "Process and store conversation learnings",
|
|
@@ -226,15 +236,18 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
226
236
|
"unipi:web-settings": "Configure web search settings",
|
|
227
237
|
"unipi:web-cache-clear": "Clear web search cache",
|
|
228
238
|
|
|
229
|
-
"unipi:compact":
|
|
239
|
+
"unipi:lossless-compact": "Immediate zero-LLM compaction",
|
|
240
|
+
"unipi:compact": "(DEPRECATED) Use /unipi:lossless-compact instead",
|
|
230
241
|
"unipi:compact-recall": "Recall a compacted session",
|
|
231
242
|
"unipi:compact-stats": "Show compaction statistics",
|
|
232
243
|
"unipi:compact-doctor": "Diagnose compaction issues",
|
|
233
244
|
"unipi:compact-settings": "Configure compaction settings",
|
|
234
245
|
"unipi:compact-preset": "Manage compaction presets",
|
|
235
|
-
"unipi:
|
|
236
|
-
"unipi:
|
|
237
|
-
"unipi:
|
|
246
|
+
"unipi:cocoindex-update": "Run CocoIndex update to index project",
|
|
247
|
+
"unipi:cocoindex-status": "Show CocoIndex indexing status",
|
|
248
|
+
"unipi:cocoindex-init": "Initialize CocoIndex pipeline",
|
|
249
|
+
"unipi:cocoindex-search": "Search indexed codebase semantically",
|
|
250
|
+
"unipi:cocoindex-settings": "Show CocoIndex configuration",
|
|
238
251
|
|
|
239
252
|
"unipi:notify-settings": "Configure notification platforms and events",
|
|
240
253
|
"unipi:notify-set-gotify": "Set up Gotify push notifications",
|
|
@@ -248,6 +261,7 @@ export const COMMAND_DESCRIPTIONS: Record<string, string> = {
|
|
|
248
261
|
|
|
249
262
|
"unipi:footer": "Toggle footer or switch preset",
|
|
250
263
|
"unipi:footer-settings": "Open footer settings — toggle groups and segments",
|
|
264
|
+
"unipi:footer-help": "Show footer segment guide",
|
|
251
265
|
|
|
252
266
|
"unipi:readme": "Browse package README files",
|
|
253
267
|
"unipi:changelog": "Browse changelog (Keep a Changelog format)",
|
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/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
|
+
}
|