@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/command-enchantment",
3
- "version": "0.1.7",
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
- // Default: unipi commands first, then system commands, hide skill commands
319
- // (skill commands are redundant when unipi equivalents exist)
320
- finalItems = [...enhancedUnipiItems, ...systemItems];
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 (error) {
56
- console.error("[command-enchantment] Failed to load config:", error);
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
+ }