Skip to content

Commit f79108e

Browse files
authored
🤖 fix: align skill autocomplete matching (#3214)
## Summary Align slash and inline skill autocomplete behavior so `` matches `-simplifier` the same way `/simpl` does, and inline skill completions leave the cursor ready for continued typing. ## Background Slash skill suggestions already matched full-name prefixes and hyphen-segment prefixes, but inline `` suggestions only matched full-name prefixes. Inline completions also inserted no trailing space at end-of-input, unlike slash completions. ## Implementation - Added a shared `matchesHyphenatedPrefix` helper for full-prefix and hyphen-segment-prefix matching. - Reused that helper from slash and inline skill suggestion filtering without changing ordering or introducing fuzzy matching. - Updated inline skill insertion spacing so end-of-input completions append one trailing space while preserving existing whitespace and punctuation behavior. ## Validation - `bun test src/browser/utils/slashCommands/suggestions.test.ts src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts` - `make static-check` - Dogfooded in a dev-server sandbox with screenshots for `/simpl`, ``, and punctuation-adjacent insertion. ## Risks Low risk: the shared matcher preserves the previous slash predicate, inline changes are covered by focused behavior tests, and punctuation/whitespace insertion behavior remains explicitly tested. --- <details> <summary>📋 Implementation Plan</summary> # Plan: align `/SKILL_NAME` and `$SKILL_NAME` autocomplete UX ## Goal Make slash-command skill invocations and inline dollar skill invocations feel consistent in the chat input: 1. Selecting an inline `$SKILL_NAME` suggestion should leave the cursor ready to continue typing, matching the `/SKILL_NAME ` behavior. 2. `$SKILL_NAME` suggestions should match skill names the same way slash skill commands do, including hyphen-segment prefix matches such as `simpl` → `code-simplifier`. ## Evidence gathered Read-only investigation found the user's claims are valid: - `/` suggestions are built in `src/browser/utils/slashCommands/suggestions.ts`. - Matching: full prefix OR hyphen-segment prefix. - Replacement: `/${definition.key}${appendSpace ? " " : ""}`, with `appendSpace` defaulting to `true`. - `$` suggestions are built in `src/browser/utils/agentSkills/inlineSkillSuggestions.ts`. - Matching: strict `descriptor.name.startsWith(lowered)` only. - Replacement: `$${descriptor.name}` with no baked-in space. - Selection handlers live in `src/browser/features/ChatInput/index.tsx`. - `/` handler uses `setInput(suggestion.replacement)`; the replacement already includes the trailing space. - `$` handler splices `suggestion.replacement + getInlineSkillInsertionTrailingText(after) + after`. - `getInlineSkillInsertionTrailingText(after)` currently returns `""` at end-of-input, so selecting `$code-simplifier` at the end does not add a space. ## Advisor review incorporated The advisor agreed with the shared-helper direction, with refinements: - Put the matcher in a neutral location/name so generic slash commands do not import from `agentSkills`. - Be explicit that this is **not fuzzy matching**: preserve current slash behavior exactly as case-insensitive full-prefix OR hyphen-segment-prefix matching, with no scoring or reordering. - Update tests in the same phase as each implementation change so the plan does not intentionally create a failing intermediate step. - Keep `$` replacement as `$${name}` and only change the trailing-text helper; baking the space into `$` replacements would break punctuation/adjacent-text cases. - Add a ChatInput-level selection/cursor test only if an existing harness makes it cheap; otherwise rely on helper tests plus dogfooding. ## Recommended approach — shared matcher plus `$` trailing-space alignment **Net product LoC estimate:** +10 to +25 LoC. ### Phase 1 — Introduce neutral hyphen-prefix matching and use it in both flows 1. Add a tiny neutral helper rather than putting shared logic under either feature-specific directory: - Preferred location: `src/browser/utils/suggestionMatching.ts` - Preferred name: `matchesHyphenatedPrefix(name, partial)` 2. Implement the existing `/` algorithm exactly: - Normalize to lowercase. - Match an empty partial. - Match if the whole name starts with the partial. - Match if any `-`-separated segment starts with the partial. - Do **not** add fuzzy scoring, substring matching, sorting, Fuse, or any ordering changes. ```ts export function matchesHyphenatedPrefix(name: string, partial: string): boolean { const normalizedPartial = partial.trim().toLowerCase(); const normalizedName = name.toLowerCase(); return normalizedPartial === "" || normalizedName.startsWith(normalizedPartial) || normalizedName.split("-").some((segment) => segment.startsWith(normalizedPartial)); } ``` 3. Use the helper in: - `src/browser/utils/slashCommands/suggestions.ts` - `src/browser/utils/agentSkills/inlineSkillSuggestions.ts` 4. Preserve existing suggestion ordering; filtering should not sort or score results. 5. Be deliberate about `partial.trim()`: - This mirrors current slash behavior. - Add or preserve coverage for empty/whitespace partials if inline parser behavior makes whitespace partials possible. **Phase 1 tests / quality gate:** - Update/add tests in `src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts`: - `$simpl` returns `$code-simplifier` via the `simplifier` segment. - `$review` returns `$deep-review` or equivalent via the `review` segment. - Substring-only matches still do **not** match, e.g. `view` does not match `deep-review`. - Result order follows descriptor order. - Keep existing slash suggestion tests passing to confirm no slash behavior changed. ```sh bun test src/browser/utils/slashCommands/suggestions.test.ts src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts ``` ### Phase 2 — Make `$` selection append a usable trailing space at end-of-input 1. Update only `getInlineSkillInsertionTrailingText(after)` in `src/browser/utils/agentSkills/inlineSkillSuggestions.ts`: ```ts export function getInlineSkillInsertionTrailingText(after: string): "" | " " { return after.length === 0 ? " " : INLINE_SKILL_INSERT_EXISTING_SEPARATOR_RE.test(after[0] ?? "") ? "" : " "; } ``` 2. Keep `getInlineSkillSuggestions()` replacements as `$${descriptor.name}`. The `$` flow must continue deciding spacing at insertion time because it can complete before punctuation or adjacent text. 3. Preserve smart-skip behavior when the next character is already whitespace, punctuation, or a closer, so `$skill,` does not become `$skill ,`. 4. Confirm `src/browser/features/ChatInput/index.tsx` already computes cursor position using `suggestion.replacement.length + trailing.length`; only touch it if the current cursor math fails the new EOF-space behavior. **Phase 2 tests / quality gate:** - Update trailing-text tests in `src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts`: - `after === ""` → `" "`. - `after === " "` → `""`. - `after === ","` and `after === ")"` → `""`. - `after === "next"` → `" "`. - If an existing ChatInput test harness supports this cheaply, add one integration-ish selection test for `$simpl` that confirms the final value/cursor lands after `$code-simplifier `. - Do not build a new heavy harness just for this; helper tests plus dogfooding are sufficient if no harness exists. ```sh bun test src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts ``` ### Phase 3 — Full local validation Run validation after the product and test changes are complete: ```sh make typecheck make lint bun test src/browser/utils/slashCommands/suggestions.test.ts src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts ``` If the branch already has broader UI-related changes, prefer `make static-check` before handoff. ## Dogfooding plan Because this is chat-input UX, dogfood in the actual app after tests pass. 1. Start the app in a clean-ish dev environment: ```sh make dev ``` 2. Use `agent-browser` to interact with the running app: - Open the Electron/web dev URL exposed by the dev command. - Navigate to a workspace chat input. - Type `/simpl`, select `code-simplifier`, press Enter, and confirm the input remains `/code-simplifier ` with the cursor after the space. - Type `$simpl`, select `code-simplifier`, press Enter, and confirm the input becomes `$code-simplifier ` with the cursor after the space. - Type `$simpl, thanks`, place the cursor after `simpl`, select `code-simplifier`, and confirm the input becomes `$code-simplifier, thanks` with no extra space before the comma. - Type `$simplnext`, place the cursor after `simpl`, select `code-simplifier`, and confirm the input becomes `$code-simplifier next` with exactly one separating space before `next`. - Optional but useful: submit `$code-simplifier ` and confirm the trailing space does not break skill invocation parsing. 3. Capture reviewer evidence: - Save screenshots before and after selecting `/simpl`. - Save screenshots before and after selecting `$simpl`. - Record a short video showing `/simpl`, `$simpl`, and one punctuation/adjacent-text case with cursor placement. - If the environment blocks screenshots or video, report the exact blocker and still provide the strongest available evidence. ## Acceptance criteria - `$simpl` surfaces `$code-simplifier` when `code-simplifier` is an available skill. - `$` and `/` suggestions use the same case-insensitive full-prefix and hyphen-segment-prefix matching behavior. - Matching remains prefix/segment-prefix only; substring-only input such as `view` does not match `deep-review`. - Suggestion order is preserved; no fuzzy scoring or sorting is introduced. - Selecting `$code-simplifier` at the end of input inserts `$code-simplifier `, including exactly one trailing space. - Selecting `$code-simplifier` before whitespace or punctuation does not insert an extra unwanted space. - Existing slash command behavior remains unchanged. - Targeted unit tests pass. - Manual dogfooding demonstrates both matching and trailing-space behavior with screenshots and a video recording unless the environment prevents capture, in which case the blocker is documented. ## Risks and mitigations - **Risk:** Changing `$` end-of-input behavior may surprise users who intentionally want `$skill` with no trailing space. - **Mitigation:** This aligns with existing `/` behavior and normal autocomplete UX; punctuation/closer cases still avoid unwanted spaces. - **Risk:** Sharing matching with slash commands may unintentionally alter non-skill slash commands. - **Mitigation:** Make the helper generic, preserve the exact current slash predicate, preserve ordering, and keep slash tests passing. - **Risk:** `partial.trim()` may subtly change inline `$` behavior if whitespace partials are possible. - **Mitigation:** This intentionally mirrors current slash behavior; add coverage or verify parser behavior for empty/whitespace partials during implementation. - **Risk:** Over-refactoring suggestion code could expand the diff. - **Mitigation:** Prefer one tiny neutral helper and surgical call-site changes only. ## Alternative approaches considered ### Alternative A — Patch `$` only, leave `/` implementation unchanged **Net product LoC estimate:** +5 to +15 LoC. - Change `$` matching to copy the slash segment-aware predicate inline. - Change `$` trailing helper to return a space at end-of-input. - Pros: smallest possible diff and acceptable if a neutral helper creates awkward imports or broad churn. - Cons: duplicates matching logic and allows the two flows to drift again. ### Alternative B — Preserve `$` smart no-space-at-EOL behavior, only fix matching **Net product LoC estimate:** +3 to +10 LoC. - Only update `$` matching so `$simpl` works. - Pros: very low risk. - Cons: leaves the reported trailing-space inconsistency unresolved. ### Recommendation Use the recommended shared-helper approach. It remains small, removes the observed mismatch, preserves slash behavior, and prevents future drift between slash skill invocation and inline skill invocation matching. If the shared helper unexpectedly causes a large diff, fall back to Alternative A rather than expanding the scope. </details> --- _Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `high` • Cost: `1326901{MUX_COSTS_USD:-unknown}`_ <!-- mux-attribution: model=openai:gpt-5.5 thinking=high costs=11.97 -->
1 parent 6ea6964 commit f79108e

5 files changed

Lines changed: 80 additions & 19 deletions

File tree

src/browser/utils/agentSkills/inlineSkillSuggestions.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,33 @@ describe("getInlineSkillSuggestions", () => {
4646
).toEqual(["$tdd", "$tdd-review"]);
4747
});
4848

49+
test("matches hyphenated skill name segments", () => {
50+
expect(
51+
getInlineSkillSuggestions({
52+
partial: "simpl",
53+
descriptors: [descriptor("tdd"), descriptor("code-simplifier")],
54+
}).map((suggestion) => suggestion.display)
55+
).toEqual(["$code-simplifier"]);
56+
});
57+
58+
test("matches later hyphenated skill name segments", () => {
59+
expect(
60+
getInlineSkillSuggestions({
61+
partial: "review",
62+
descriptors: [descriptor("tdd"), descriptor("deep-review")],
63+
}).map((suggestion) => suggestion.display)
64+
).toEqual(["$deep-review"]);
65+
});
66+
67+
test("does not match substring-only partials", () => {
68+
expect(
69+
getInlineSkillSuggestions({
70+
partial: "view",
71+
descriptors: [descriptor("deep-review")],
72+
}).map((suggestion) => suggestion.display)
73+
).toEqual([]);
74+
});
75+
4976
test("matches uppercase partials against canonical lowercase names", () => {
5077
expect(
5178
getInlineSkillSuggestions({
@@ -130,8 +157,11 @@ describe("getInlineSkillInsertionTrailingText", () => {
130157
}
131158
});
132159

133-
test("preserves existing whitespace and end-of-line behavior", () => {
134-
expect(getInlineSkillInsertionTrailingText("")).toBe("");
160+
test("adds a trailing space at the end of the input", () => {
161+
expect(getInlineSkillInsertionTrailingText("")).toBe(" ");
162+
});
163+
164+
test("preserves existing whitespace", () => {
135165
expect(getInlineSkillInsertionTrailingText(" next")).toBe("");
136166
expect(getInlineSkillInsertionTrailingText("\nnext")).toBe("");
137167
});

src/browser/utils/agentSkills/inlineSkillSuggestions.ts

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { matchesNameBySegmentPrefix } from "@/browser/utils/suggestionMatching";
12
import type { SlashSuggestion } from "@/browser/utils/slashCommands/types";
23
import type { AgentSkillDescriptor } from "@/common/types/agentSkill";
34

@@ -29,18 +30,17 @@ export function shouldRefreshInlineSkillSuggestions(
2930
}
3031

3132
export function getInlineSkillInsertionTrailingText(after: string): "" | " " {
32-
// Characters where inserting a space before them would be wrong: whitespace,
33-
// sentence punctuation, and closers that should bind to the skill reference.
34-
return after.length === 0 || INLINE_SKILL_INSERT_EXISTING_SEPARATOR_RE.test(after[0] ?? "")
35-
? ""
36-
: " ";
33+
// At end-of-input, add a space so the cursor is ready for continued typing.
34+
// Before whitespace, punctuation, or closers, skip the space to avoid doubling.
35+
if (after.length === 0) return " ";
36+
if (INLINE_SKILL_INSERT_EXISTING_SEPARATOR_RE.test(after[0] ?? "")) return "";
37+
return " ";
3738
}
3839

3940
/**
4041
* Returns suggestions for `$skill` autocomplete.
4142
*
42-
* - Filter rule: descriptor.name.startsWith(partial). Case-sensitive skill names are
43-
* canonical lowercase IDs (validated by SkillNameSchema), so normalize the user's partial.
43+
* - Filter rule: full-name prefix or hyphen-segment prefix, matching slash skill commands.
4444
* - Empty `partial` returns the full descriptor list (so typing just `$` opens the menu).
4545
* - Result order: descriptors order from caller (no re-sort). Caller already lists in
4646
* scope-priority order.
@@ -50,9 +50,8 @@ export function getInlineSkillInsertionTrailingText(after: string): "" | " " {
5050
export function getInlineSkillSuggestions(
5151
context: InlineSkillSuggestionContext
5252
): SlashSuggestion[] {
53-
const lowered = context.partial.toLowerCase();
5453
return context.descriptors
55-
.filter((descriptor) => descriptor.name.startsWith(lowered))
54+
.filter((descriptor) => matchesNameBySegmentPrefix(descriptor.name, context.partial))
5655
.map((descriptor) => ({
5756
id: `inline-skill:${descriptor.name}`,
5857
display: `$${descriptor.name}`,

src/browser/utils/slashCommands/suggestions.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* Slash command suggestions generation
33
*/
44

5+
import { matchesNameBySegmentPrefix } from "@/browser/utils/suggestionMatching";
56
import { MODEL_ABBREVIATIONS } from "@/common/constants/knownModels";
67
import { EXPERIMENT_IDS } from "@/common/constants/experiments";
78
import { formatModelDisplayName } from "@/common/utils/ai/modelDisplay";
@@ -27,17 +28,10 @@ function filterAndMapSuggestions<T extends SuggestionDefinition>(
2728
build: (definition: T) => SlashSuggestion,
2829
filter?: (definition: T) => boolean
2930
): SlashSuggestion[] {
30-
const normalizedPartial = partial.trim().toLowerCase();
31-
3231
return definitions
3332
.filter((definition) => {
3433
if (filter && !filter(definition)) return false;
35-
const normalizedKey = definition.key.toLowerCase();
36-
// Keep full-prefix matches (e.g., /deep-r) alongside segment matches (e.g., /r).
37-
return normalizedPartial
38-
? normalizedKey.startsWith(normalizedPartial) ||
39-
normalizedKey.split("-").some((segment) => segment.startsWith(normalizedPartial))
40-
: true;
34+
return matchesNameBySegmentPrefix(definition.key, partial);
4135
})
4236
.map((definition) => build(definition));
4337
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { matchesNameBySegmentPrefix } from "./suggestionMatching";
3+
4+
describe("matchesNameBySegmentPrefix", () => {
5+
test("matches empty and whitespace-only partials", () => {
6+
expect(matchesNameBySegmentPrefix("deep-review", "")).toBe(true);
7+
expect(matchesNameBySegmentPrefix("deep-review", " ")).toBe(true);
8+
});
9+
10+
test("matches full-name prefixes case-insensitively", () => {
11+
expect(matchesNameBySegmentPrefix("deep-review", "DEEP-R")).toBe(true);
12+
});
13+
14+
test("matches hyphen-delimited segment prefixes", () => {
15+
expect(matchesNameBySegmentPrefix("code-simplifier", "simpl")).toBe(true);
16+
expect(matchesNameBySegmentPrefix("deep-review", "review")).toBe(true);
17+
});
18+
19+
test("does not match substring-only partials", () => {
20+
expect(matchesNameBySegmentPrefix("deep-review", "view")).toBe(false);
21+
});
22+
});
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
/**
2+
* Case-insensitive prefix match against a hyphenated name.
3+
*
4+
* Returns true when `partial` is a prefix of the full name or any
5+
* hyphen-delimited segment. Empty or whitespace-only partials match everything.
6+
*/
7+
export function matchesNameBySegmentPrefix(name: string, partial: string): boolean {
8+
const normalizedPartial = partial.trim().toLowerCase();
9+
const normalizedName = name.toLowerCase();
10+
11+
return (
12+
normalizedPartial.length === 0 ||
13+
normalizedName.startsWith(normalizedPartial) ||
14+
normalizedName.split("-").some((segment) => segment.startsWith(normalizedPartial))
15+
);
16+
}

0 commit comments

Comments
 (0)