From 32f354002e48f63fcb37bf5c4d599371403d4982 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Sun, 5 Apr 2026 10:54:34 -0700 Subject: [PATCH 01/10] optionalSpacePunct --- ts/docs/architecture/actionGrammar.md | 30 +- ts/docs/architecture/completion.md | 8 +- .../actionGrammar/src/grammarCompletion.ts | 10 +- .../actionGrammar/src/grammarMatcher.ts | 42 ++- ...mmarCompletionCategory3bLimitation.spec.ts | 12 +- ...grammarCompletionKeywordSpacePunct.spec.ts | 304 +++++++++--------- .../grammarCompletionLongestMatch.spec.ts | 16 +- ...ammarCompletionMultiWordKeywordEOI.spec.ts | 6 +- .../grammarCompletionPrefixLength.spec.ts | 80 ++--- .../grammarCompletionSpacingNested.spec.ts | 58 ++-- ts/packages/actionGrammar/test/testUtils.ts | 3 +- ts/packages/agentSdk/src/command.ts | 20 +- .../agentSdk/src/helpers/commandHelpers.ts | 10 +- ts/packages/cache/src/cache/grammarStore.ts | 4 +- .../src/constructions/constructionCache.ts | 8 +- ts/packages/cache/test/completion.spec.ts | 10 +- .../cache/test/crossGrammarConflict.spec.ts | 4 +- .../cache/test/mergeCompletionResults.spec.ts | 2 +- .../dispatcher/src/command/completion.ts | 28 +- .../dispatcher/test/completion.spec.ts | 8 +- .../renderer/src/partialCompletionSession.ts | 47 +-- .../test/partialCompletion/grammarE2E.spec.ts | 6 +- .../partialCompletion/separatorMode.spec.ts | 131 +++++++- .../startIndexSeparatorContract.spec.ts | 8 +- .../stateTransitions.spec.ts | 2 +- 25 files changed, 512 insertions(+), 345 deletions(-) diff --git a/ts/docs/architecture/actionGrammar.md b/ts/docs/architecture/actionGrammar.md index d83d187e9..cd87b31d4 100644 --- a/ts/docs/architecture/actionGrammar.md +++ b/ts/docs/architecture/actionGrammar.md @@ -133,21 +133,21 @@ evaluated against the adjacent characters to produce a `separatorMode` [Completion matching](#completion-matching-matchgrammarcompletion) and `completion.md`): -| Annotation | `CompiledSpacingMode` | Resulting `separatorMode` | -| -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| _(none / default)_ | `auto` | `"spacePunctuation"` if both adjacent characters are word-boundary scripts (Latin, Cyrillic, etc.); `"optional"` if either is CJK or another non-word-boundary script | -| `[spacing=required]` | `"required"` | Always `"spacePunctuation"` | -| `[spacing=optional]` | `"optional"` | Always `"optional"` | -| `[spacing=none]` | `"none"` | Always `"none"` — no separator consumed or required | +| Annotation | `CompiledSpacingMode` | Resulting `separatorMode` | +| -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(none / default)_ | `auto` | `"spacePunctuation"` if both adjacent characters are word-boundary scripts (Latin, Cyrillic, etc.); `"optionalSpace"` if either is CJK or another non-word-boundary script | +| `[spacing=required]` | `"required"` | Always `"spacePunctuation"` | +| `[spacing=optional]` | `"optional"` | Always `"optionalSpacePunctuation"` | +| `[spacing=none]` | `"none"` | Always `"none"` — no separator consumed or required | **Note:** The table above describes the _baseline_ `separatorMode` from the spacing annotation. When the consumed prefix already ends with whitespace (i.e., the separator is already present in `matchedPrefixLength`), -the grammar matcher overrides to `"optional"` because no additional +the grammar matcher overrides to `"optionalSpace"` because no additional separator is needed. Digits are Unicode script "Common" (not a word-boundary script), so `auto` spacing at a digit–Latin boundary (e.g., `$(n:number)` followed by a Latin keyword) also produces -`"optional"`. +`"optionalSpace"`. ### Entities @@ -777,7 +777,7 @@ Rationale: `separatorMode` accurately reflects the grammar's spacing annotation (e.g., `"spacePunctuation"` for Latin auto-spacing). If P advanced past the space, the separator is already present and `separatorMode` - collapses to `"optional"` — losing the information about what kind of + collapses to `"optionalSpace"` — losing the information about what kind of separator the grammar expects. The shell needs the un-collapsed mode to decide whether a non-space punctuation character should trigger a re-fetch. @@ -822,16 +822,16 @@ already covers this case. - `separatorMode` — determined by the grammar rule's `[spacing=...]` annotation (see [Spacing modes](#spacing-modes) above). Special cases: - When `matchedPrefixLength=0` (nothing consumed), `separatorMode` is - always `"optional"` (or `"none"` for `[spacing=none]` rules) because + always `"optionalSpace"` (or `"none"` for `[spacing=none]` rules) because there is no preceding character to require a separator against. - When the consumed prefix already ends with whitespace (e.g., - `"play "`), `separatorMode` is `"optional"` because the separator is + `"play "`), `separatorMode` is `"optionalSpace"` because the separator is already present — no additional separator is needed. - For `auto` spacing, `"spacePunctuation"` is produced only when both the last consumed character and the first completion character are word-boundary scripts (Latin, Cyrillic, etc.) and no separator has been consumed; digit–Latin transitions (e.g., `"50"` → `"percent"`) - produce `"optional"` because digits are Unicode script "Common", not + produce `"optionalSpace"` because digits are Unicode script "Common", not a word-boundary script. - `closedSet` is `true` when all completions are grammar keywords (no entity/wildcard values). @@ -886,7 +886,7 @@ Three-way compatibility: and candidates were dropped, advance `maxPrefixLength` by exactly one character (not past all consecutive separators). This ensures backspace triggers a re-fetch (the anchor diverges). Override - `separatorMode` to `"optional"` since the separator is already + `separatorMode` to `"optionalSpace"` since the separator is already consumed into P. Advance-1 is preferred over advance-all because: @@ -896,10 +896,10 @@ Three-way compatibility: - With advance-all, deleting the _last_ separator in a multi- separator run is the only keystroke that triggers a re-fetch; intermediate deletes are invisible to the completion system. - - Advance-1 matches the shell's `separatorMode="optional"` contract: + - Advance-1 matches the shell's `separatorMode="optionalSpace"` contract: the session sees one consumed separator and treats the rest as ordinary prefix text. The shell strips leading whitespace for - `"optional"` mode (just as it does for requiring modes), so extra + `"optionalSpace"` mode (just as it does for requiring modes), so extra separators do not pollute the trie — the menu stays visible with an empty or narrowed prefix. - The re-fetch cost is negligible — the grammar matcher runs in diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index a51e6d184..defe06375 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -92,7 +92,7 @@ The return path carries `CommandCompletionResult`: { startIndex: number; // where the resolved prefix ends completions: CompletionGroup[]; - separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optional" | "none" + separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optionalSpacePunctuation" | "optionalSpace" | "none" closedSet: boolean; // true → list is exhaustive directionSensitive: boolean; // true → completion(input[0..P], backward) ≠ completion(input[0..P], forward) afterWildcard: AfterWildcard; // "none" | "some" | "all" — wildcard boundary ambiguity @@ -414,7 +414,7 @@ contiguous within each category. the backend. Everything after the anchor is the `completionPrefix` used to filter the local trie. - **Separator stripping**: when `separatorMode` requires a separator - (`"space"` or `"spacePunctuation"`), or is `"optional"`, leading + (`"space"` or `"spacePunctuation"`), or is `"optionalSpace"` / `"optionalSpacePunctuation"`, leading separator characters in the raw prefix are stripped before trie lookup. This means extra whitespace (e.g. double space) does not leak into the trie as filter text — the trie always sees clean completion prefixes. @@ -465,7 +465,7 @@ text. | -------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `"space"` | Whitespace required | Commands, flags, agent names | | `"spacePunctuation"` | Whitespace or Unicode punctuation | Latin-script grammar completions | -| `"optional"` | Separator accepted but not required | CJK / mixed-script grammars; also digit–Latin boundaries (digits are Unicode script "Common", not "Latin", so a transition like `"0"→"i"` is a script change that does not require a separator) | +| `"optionalSpace"` | Separator accepted but not required | CJK / mixed-script grammars; also digit–Latin boundaries (digits are Unicode script "Common", not "Latin", so a transition like `"0"→"i"` is a script change that does not require a separator) | | `"none"` | No separator | Grammar rules annotated with `[spacing=none]`. At the top level, no leading or trailing whitespace is consumed. For nested rules, the parent rule's spacing controls the boundaries around the child; the child's `"none"` only affects its own internal token boundaries. | See `actionGrammar.md` Spacing modes for how the grammar matcher @@ -842,7 +842,7 @@ _Impact:_ Premature "accept" when one source is open — user misses completions from that source. **#13 — `separatorMode`: strongest requirement wins.** -`"space"` > `"spacePunctuation"` > `"optional"` > `"none"`. +`"space"` > `"spacePunctuation"` > `"optionalSpacePunctuation"` > `"optionalSpace"` > `"none"`. _Impact:_ Fused display if a weak mode wins over a strong one, or unnecessary separation if the reverse. diff --git a/ts/packages/actionGrammar/src/grammarCompletion.ts b/ts/packages/actionGrammar/src/grammarCompletion.ts index 7159b77c8..01c92954b 100644 --- a/ts/packages/actionGrammar/src/grammarCompletion.ts +++ b/ts/packages/actionGrammar/src/grammarCompletion.ts @@ -317,7 +317,7 @@ export type GrammarCompletionResult = { // spacingMode) but distinct from them. // "spacePunctuation" — whitespace or punctuation required // (Latin "y" → "m" requires a separator). - // "optional" — separator accepted but not required + // "optionalSpace" — separator accepted but not required // (CJK 再生 → 音楽 does not require a separator). // "none" — no separator at all ([spacing=none] grammars). // Omitted when no completions were generated. @@ -1488,7 +1488,7 @@ function filterSepConflicts(ctx: CompletionContext): void { // A true separator conflict occurs only when "none" mode // (rejects separator) coexists with requiring modes (needs - // separator). "optional" is compatible with both states and + // separator). "optionalSpace" is compatible with both states and // does not participate in the conflict. ctx.hasSepConflict = hasRequiring && hasNoneMode; if (!ctx.hasSepConflict) return; @@ -1516,7 +1516,7 @@ function filterSepConflicts(ctx: CompletionContext): void { // re-fetch. Only one character (not all consecutive // separators) is consumed: each backspace in a multi-separator // run produces a distinct anchor. Remaining separators are - // stripped by the shell's "optional" mode handling, keeping the + // stripped by the shell's "optionalSpace" mode handling, keeping the // menu visible with a clean trie prefix. // // Safe unconditionally: hasSepConflict is true (checked above), @@ -1822,7 +1822,7 @@ function materializeCandidates( // When P was advanced past trailing separator chars (trailing-sep // conflict path), the separator is already consumed into P. - // Override separatorMode to "optional" — no *additional* separator + // Override separatorMode to "optionalSpace" — no *additional* separator // is required between the (advanced) P and the completion text. // mergeSepMode may have computed "spacePunctuation" because // input[P-1] is a separator character, but that separator is the @@ -1837,7 +1837,7 @@ function materializeCandidates( ctx.direction !== "backward" && separatorMode !== undefined ) { - separatorMode = "optional"; + separatorMode = "optionalSpace"; } // Range candidates replace the old two-pass backward retrigger diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 290f1dc15..61c00a4fe 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -19,9 +19,14 @@ import { // SeparatorMode from @typeagent/agent-sdk (command.ts); independently // defined here so actionGrammar does not depend on agentSdk. Keep // both definitions in sync. The grammar matcher only produces -// "spacePunctuation", "optional", and "none" — never "space" -// (which is strictly command/flag-level). -export type SeparatorMode = "space" | "spacePunctuation" | "optional" | "none"; +// "spacePunctuation", "optionalSpacePunctuation", "optionalSpace", and +// "none" — never "space" (which is strictly command/flag-level). +export type SeparatorMode = + | "space" + | "spacePunctuation" + | "optionalSpacePunctuation" + | "optionalSpace" + | "none"; const debugMatchRaw = registerDebug("typeagent:grammar:match"); @@ -94,8 +99,13 @@ export function requiresSeparator( // Convert a per-candidate (needsSep, spacingMode) pair into a // SeparatorMode value. When needsSep is true (separator required), // the grammar always uses spacePunctuation separators. -// When needsSep is false: "none" spacingMode → "none", otherwise -// → "optional" (covers auto mode/CJK/mixed and explicit "optional"). +// When needsSep is false: +// "none" spacingMode → "none" +// "optional" spacingMode → "optionalSpacePunctuation" (explicit +// [spacing=optional] annotation; separator not required, but +// when present may be whitespace or punctuation) +// auto (undefined) → "optionalSpace" (CJK/digit/mixed — separator +// not required; when present only whitespace is meaningful) export function candidateSeparatorMode( needsSep: boolean, spacingMode: CompiledSpacingMode, @@ -106,12 +116,16 @@ export function candidateSeparatorMode( if (spacingMode === "none") { return "none"; } - return "optional"; + if (spacingMode === "optional") { + return "optionalSpacePunctuation"; + } + return "optionalSpace"; } // Merge a new candidate's separator mode into the running aggregate. // The mode requiring the strongest separator wins (i.e. the mode that -// demands the most from the user): space > spacePunctuation > optional > none. +// demands the most from the user): +// space > spacePunctuation > optionalSpacePunctuation > optional > none. export function mergeSeparatorMode( current: SeparatorMode | undefined, needsSep: boolean, @@ -132,9 +146,17 @@ export function mergeSeparatorMode( ) { return "spacePunctuation"; } - // "optional" is a stronger requirement than "none". - if (current === "optional" || candidateMode === "optional") { - return "optional"; + // "optionalSpacePunctuation" — separator not required but includes + // punctuation when present. Stronger than plain "optionalSpace". + if ( + current === "optionalSpacePunctuation" || + candidateMode === "optionalSpacePunctuation" + ) { + return "optionalSpacePunctuation"; + } + // "optionalSpace" is a stronger requirement than "none". + if (current === "optionalSpace" || candidateMode === "optionalSpace") { + return "optionalSpace"; } return "none"; } diff --git a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts index 523e464e1..e58179f45 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts @@ -225,7 +225,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "再生x"); expect(result.completions.sort()).toEqual(["映画", "音楽"]); expect(result.matchedPrefixLength).toBe(2); - expect(result.separatorMode).toBe("optional"); + expect(result.separatorMode).toBe("optionalSpace"); }); }); @@ -242,7 +242,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "xyz"); expect(result.completions).toEqual(["play"]); expect(result.matchedPrefixLength).toBe(0); - expect(result.separatorMode).toBe("optional"); + expect(result.separatorMode).toBe("optionalSpace"); }); }); @@ -270,11 +270,13 @@ describeForEachCompletion( ].join("\n"); const grammar = loadGrammarRules("test.grammar", g); - it("reports optional separatorMode for spacing=optional", () => { + it("reports optionalSpacePunctuation separatorMode for spacing=optional", () => { const result = matchGrammarCompletion(grammar, "play x"); expect(result.completions).toEqual(["music"]); expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("optional"); + expect(result.separatorMode).toBe( + "optionalSpacePunctuation", + ); }); }); @@ -292,7 +294,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play x"); expect(result.completions).toEqual(["音楽"]); expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("optional"); + expect(result.separatorMode).toBe("optionalSpace"); }); }); }); diff --git a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts index 71e6b7a84..927553c73 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts @@ -37,11 +37,11 @@ describeForEachCompletion( // Empty input → first keyword offered; separator before "hello," // is N/A (no prior char). separatorMode reflects the gap // between matchedPrefixLength and the completion text. - // At position 0 with no prior char, auto mode: "optional" + // At position 0 with no prior char, auto mode: "optionalSpace" expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -53,11 +53,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hel"); // Category 3b (dirty partial): "hel" partially matches "hello,". // Prefix-filter match → directionSensitive = false - // mpl=0, no prior char → "optional" + // mpl=0, no prior char → "optionalSpace" expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -70,11 +70,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello,"); // "hello," fully matched, no trailing separator. // Backward would back up; forward advances. → direction-sensitive - // requiresSeparator(",", "w", auto) → comma is punct → "optional" + // requiresSeparator(",", "w", auto) → comma is punct → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -87,7 +87,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -98,12 +98,12 @@ describeForEachCompletion( it("separatorMode: comma-ending word before Latin word", () => { // After "hello," the next char is "w" (Latin). // requiresSeparator(",", "w", auto) → false (comma not word-boundary) - // → separatorMode should be "optional" + // → separatorMode should be "optionalSpace" const result = matchGrammarCompletion(grammar, "hello,"); expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -117,7 +117,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -128,11 +128,11 @@ describeForEachCompletion( it("offers second segment for partial second word 'wor'", () => { const result = matchGrammarCompletion(grammar, "hello, wor"); // Category 3b: "wor" partially matches "world" → prefix-filter - // requiresSeparator(",", "w", auto) → comma is punct → "optional" + // requiresSeparator(",", "w", auto) → comma is punct → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -155,7 +155,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -166,11 +166,11 @@ describeForEachCompletion( it("offers second segment after 'hello'", () => { const result = matchGrammarCompletion(grammar, "hello"); // "hello" fully matched, no trailing sep → direction-sensitive - // requiresSeparator("o", ",", auto) → comma is punct → "optional" + // requiresSeparator("o", ",", auto) → comma is punct → "optionalSpace" expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -181,12 +181,12 @@ describeForEachCompletion( it("separatorMode: Latin word before comma-starting word", () => { // After "hello" the next char is "," (punctuation). // requiresSeparator("o", ",", auto) → false (comma not word-boundary) - // → separatorMode should be "optional" + // → separatorMode should be "optionalSpace" const result = matchGrammarCompletion(grammar, "hello"); expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -199,7 +199,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -213,7 +213,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -236,7 +236,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -247,11 +247,11 @@ describeForEachCompletion( it("offers dot segment after 'hello'", () => { const result = matchGrammarCompletion(grammar, "hello"); // "hello" fully matched, no trailing sep → direction-sensitive - // requiresSeparator("o", ".", auto) → dot is punct → false → "optional" + // requiresSeparator("o", ".", auto) → dot is punct → false → "optionalSpace" expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -262,11 +262,11 @@ describeForEachCompletion( it("offers 'world' after 'hello.'", () => { const result = matchGrammarCompletion(grammar, "hello."); // "hello." two segments matched, no trailing sep → direction-sensitive - // requiresSeparator(".", "w", auto) → false → "optional" + // requiresSeparator(".", "w", auto) → false → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -279,7 +279,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 7, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -293,7 +293,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 7, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -316,7 +316,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -330,7 +330,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -344,7 +344,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -357,7 +357,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -427,7 +427,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -439,11 +439,11 @@ describeForEachCompletion( // "hello " fully matches segment "hello " const result = matchGrammarCompletion(grammar, "hello "); // "hello " fully matched, no trailing sep → direction-sensitive - // requiresSeparator(" ", "w", auto) → space is not script boundary → "optional" + // requiresSeparator(" ", "w", auto) → space is not script boundary → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -493,7 +493,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -505,11 +505,11 @@ describeForEachCompletion( // "hello" fully matches first segment; next is " world" const result = matchGrammarCompletion(grammar, "hello"); // "hello" fully matched, no trailing sep → direction-sensitive - // requiresSeparator("o", " ", auto) → space is not word-boundary → "optional" + // requiresSeparator("o", " ", auto) → space is not word-boundary → "optionalSpace" expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -519,12 +519,12 @@ describeForEachCompletion( it("separatorMode after 'hello' before ' world'", () => { // requiresSeparator("o", " ", auto) → " " not word-boundary → false - // → separatorMode = "optional" + // → separatorMode = "optionalSpace" const result = matchGrammarCompletion(grammar, "hello"); expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -580,7 +580,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -602,12 +602,12 @@ describeForEachCompletion( undefined, "backward", ); - // requiresSeparator("o", " ", auto) → "optional" + // requiresSeparator("o", " ", auto) → "optionalSpace" // Backward differs from forward → direction-sensitive expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -642,12 +642,12 @@ describeForEachCompletion( undefined, "backward", ); - // mpl=0 → "optional" + // mpl=0 → "optionalSpace" // Backed up to start — at P=0 forward and backward agree expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -665,7 +665,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -760,7 +760,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -837,7 +837,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -851,7 +851,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -864,7 +864,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -902,7 +902,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["set:"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -913,11 +913,11 @@ describeForEachCompletion( it("offers 'value' after 'set:'", () => { const result = matchGrammarCompletion(grammar, "set:"); // "set:" fully matched, no trailing sep → direction-sensitive - // requiresSeparator(":", "v", auto) → colon is punct → "optional" + // requiresSeparator(":", "v", auto) → colon is punct → "optionalSpace" expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -930,7 +930,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -954,12 +954,12 @@ describeForEachCompletion( }); it("separatorMode: colon-ending word before Latin word", () => { - // requiresSeparator(":", "v", auto) → false → "optional" + // requiresSeparator(":", "v", auto) → false → "optionalSpace" const result = matchGrammarCompletion(grammar, "set:"); expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -982,7 +982,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -992,11 +992,11 @@ describeForEachCompletion( it("offers '...' after 'hello'", () => { const result = matchGrammarCompletion(grammar, "hello"); - // requiresSeparator("o", ".", auto) → dot is punct → false → "optional" + // requiresSeparator("o", ".", auto) → dot is punct → false → "optionalSpace" expectMetadata(result, { completions: ["..."], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1006,11 +1006,11 @@ describeForEachCompletion( it("offers 'world' after 'hello...'", () => { const result = matchGrammarCompletion(grammar, "hello..."); - // requiresSeparator(".", "w", auto) → false → "optional" + // requiresSeparator(".", "w", auto) → false → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 8, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1023,7 +1023,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 9, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1046,7 +1046,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1060,11 +1060,11 @@ describeForEachCompletion( // backward would back up → direction-sensitive. // Consistent with Latin keyword behavior (e.g., "hello" // in grammar "hello done" → directionSensitive=true). - // requiresSeparator(".", "d", auto) → false → "optional" + // requiresSeparator(".", "d", auto) → false → "optionalSpace" expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1081,11 +1081,11 @@ describeForEachCompletion( ); // "..." fully matched, no trailing separator → backward // backs up to offer "..." at position 0. - // mpl=0, backward exact match → "optional" + // mpl=0, backward exact match → "optionalSpace" expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1098,7 +1098,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1121,11 +1121,11 @@ describeForEachCompletion( // Wildcard finalized at EOI, keyword follows → afterWildcard // Keyword completion → closedSet true // Wildcard finalized at EOI → direction-sensitive - // requiresSeparator("o", ",", auto) → false → "optional" + // requiresSeparator("o", ",", auto) → false → "optionalSpace" expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1140,7 +1140,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1153,11 +1153,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello,d"); // Forward findPartialKeywordInWildcard finds ",d" at // position 5 as partial of ",done" → mpl=5. - // requiresSeparator("o", ",", auto) → comma is punct → "optional" + // requiresSeparator("o", ",", auto) → comma is punct → "optionalSpace" expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1182,11 +1182,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello,"); // Entity wildcard → closedSet false // Keyword matched, wildcard is next (not at EOI boundary) → afterWildcard "none" - // requiresSeparator(",", "a", auto) → comma is punct → "optional" + // requiresSeparator(",", "a", auto) → comma is punct → "optionalSpace" expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1207,7 +1207,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1229,7 +1229,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1259,11 +1259,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello, foo"); // Wildcard finalized at EOI, keyword follows → afterWildcard // Keyword completion → closedSet true - // requiresSeparator("o", ".", auto) → dot is punct → "optional" + // requiresSeparator("o", ".", auto) → dot is punct → "optionalSpace" expectMetadata(result, { completions: [".world"], matchedPrefixLength: 10, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1273,12 +1273,12 @@ describeForEachCompletion( it("offers '.world' terminator after wildcard + space", () => { const result = matchGrammarCompletion(grammar, "hello, foo "); - // Trailing space absorbed by wildcard → "optional" + // Trailing space absorbed by wildcard → "optionalSpace" // Wildcard finalized at EOI → direction-sensitive expectMetadata(result, { completions: [".world"], matchedPrefixLength: 10, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1302,12 +1302,12 @@ describeForEachCompletion( undefined, "backward", ); - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" // Backward differs from forward → direction-sensitive expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1342,12 +1342,12 @@ describeForEachCompletion( "backward", ); // "hello," fully matched, no trailing separator → backs up - // mpl=0 → "optional" + // mpl=0 → "optionalSpace" // Backed up to start — at P=0 forward and backward agree expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1365,7 +1365,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1391,12 +1391,12 @@ describeForEachCompletion( ); // "hello world" is one segment — no trailing separator, // backward should back up to start - // mpl=0 → "optional" + // mpl=0 → "optionalSpace" // Backed up to start — at P=0 forward and backward agree expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1439,7 +1439,7 @@ describeForEachCompletion( completions: ["hello,", "hello."], sortCompletions: true, matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1450,11 +1450,11 @@ describeForEachCompletion( it("offers 'world' after 'hello,'", () => { const result = matchGrammarCompletion(grammar, "hello,"); // Only the comma variant should match - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1465,11 +1465,11 @@ describeForEachCompletion( it("offers 'world' after 'hello.'", () => { const result = matchGrammarCompletion(grammar, "hello."); // Only the dot variant should match - // requiresSeparator(".", "w", auto) → "optional" + // requiresSeparator(".", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1494,7 +1494,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1504,11 +1504,11 @@ describeForEachCompletion( it("offers second segment after 'hello,'", () => { const result = matchGrammarCompletion(grammar, "hello,"); - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1544,7 +1544,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1586,11 +1586,11 @@ describeForEachCompletion( it("offers second segment after 'hello,'", () => { const result = matchGrammarCompletion(grammar, "hello,"); - // optional mode: requiresSeparator always false → "optional" + // optional mode: requiresSeparator always false → "optionalSpacePunctuation" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1598,12 +1598,12 @@ describeForEachCompletion( }); }); - it("separatorMode should be 'optional'", () => { + it("separatorMode should be 'optionalSpacePunctuation'", () => { const result = matchGrammarCompletion(grammar, "hello,"); expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1726,7 +1726,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1737,11 +1737,11 @@ describeForEachCompletion( it("offers property completion after '...'", () => { const result = matchGrammarCompletion(grammar, "..."); // Entity wildcard → closedSet false - // requiresSeparator(".", wildcard-first-char, auto) → false → "optional" + // requiresSeparator(".", wildcard-first-char, auto) → false → "optionalSpace" expectMetadata(result, { completions: [], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1762,7 +1762,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1794,7 +1794,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1804,11 +1804,11 @@ describeForEachCompletion( it("offers 'world!' after 'hello,'", () => { const result = matchGrammarCompletion(grammar, "hello,"); - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world!"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1818,11 +1818,11 @@ describeForEachCompletion( it("offers 'thanks.' after 'hello, world!'", () => { const result = matchGrammarCompletion(grammar, "hello, world!"); - // requiresSeparator("!", "t", auto) → "optional" + // requiresSeparator("!", "t", auto) → "optionalSpace" expectMetadata(result, { completions: ["thanks."], matchedPrefixLength: 13, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1839,7 +1839,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["thanks."], matchedPrefixLength: 13, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1918,12 +1918,12 @@ describeForEachCompletion( ); // "hello," fully matched as keyword word 0 → next word "world" // Wildcard absorbs all input → afterWildcard - // requiresSeparator(",", "w", auto) → comma is punct → "optional" + // requiresSeparator(",", "w", auto) → comma is punct → "optionalSpace" // Backward would differ (backs up past wildcard) → direction-sensitive expectMetadata(result, { completions: ["world"], matchedPrefixLength: 10, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1946,11 +1946,11 @@ describeForEachCompletion( // "world" is a partial prefix of "worldly", not a full match const result = matchGrammarCompletion(grammar, "hello, world"); // 3b dirty partial with prefix-filter - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["worldly"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1994,7 +1994,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2032,7 +2032,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["don't"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2088,7 +2088,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["price"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2101,7 +2101,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1.99"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2114,7 +2114,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 10, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2136,7 +2136,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2149,7 +2149,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2176,11 +2176,11 @@ describeForEachCompletion( undefined, "forward", ); - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2223,7 +2223,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2258,11 +2258,11 @@ describeForEachCompletion( it("offers 'world' after 'play hello,'", () => { const result = matchGrammarCompletion(grammar, "play hello,"); - // requiresSeparator(",", "w", auto) → "optional" + // requiresSeparator(",", "w", auto) → "optionalSpace" expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2298,11 +2298,11 @@ describeForEachCompletion( it("offers '.' terminator after wildcard content", () => { const result = matchGrammarCompletion(grammar, "hello"); // Wildcard finalized at EOI → afterWildcard - // requiresSeparator("o", ".", auto) → "optional" + // requiresSeparator("o", ".", auto) → "optionalSpace" expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2312,12 +2312,12 @@ describeForEachCompletion( it("offers '.' terminator after wildcard + space", () => { const result = matchGrammarCompletion(grammar, "hello "); - // Trailing space absorbed by wildcard → "optional" + // Trailing space absorbed by wildcard → "optionalSpace" // Wildcard finalized at EOI → direction-sensitive expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2338,12 +2338,12 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello."); // Under exhaustive matching: ["."] // Under non-exhaustive matching: would be [] - // Exhaustive: wildcard absorbed dot → "optional" + // Exhaustive: wildcard absorbed dot → "optionalSpace" // Wildcard finalized at EOI → direction-sensitive expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2368,7 +2368,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["v1.0"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2381,7 +2381,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["is"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2418,7 +2418,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2431,7 +2431,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2446,7 +2446,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2470,7 +2470,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2484,7 +2484,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2506,7 +2506,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2612,7 +2612,7 @@ describeForEachCompletion( it("offers 'hello world' after wildcard + space", () => { const result = matchGrammarCompletion(grammar, "foo "); - // Trailing space absorbed by wildcard → "optional" + // Trailing space absorbed by wildcard → "optionalSpace" // Wildcard finalized at EOI → direction-sensitive expectMetadata(result, { completions: ["hello world"], @@ -2694,7 +2694,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello, world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2738,12 +2738,12 @@ describeForEachCompletion( it("forward on 'foo' offers ',world' after wildcard", () => { const result = matchGrammarCompletion(grammar, "foo"); // Wildcard absorbs all input → mpl = input length - // requiresSeparator("o", ",", auto) → "optional" + // requiresSeparator("o", ",", auto) → "optionalSpace" // Wildcard to reconsider → direction-sensitive expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2764,7 +2764,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2788,7 +2788,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2831,7 +2831,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [","], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2854,7 +2854,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: false, afterWildcard: "none", @@ -2887,7 +2887,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2910,7 +2910,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2982,7 +2982,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3003,7 +3003,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3026,7 +3026,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 9814444af..5e8ef0015 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -22,7 +22,7 @@ describeForEachCompletion( expect(result.completions).toContain("first"); expectMetadata(result, { matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -330,7 +330,7 @@ describeForEachCompletion( expect(result.completions).toContain("percent"); expectMetadata(result, { matchedPrefixLength: 13, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -346,7 +346,7 @@ describeForEachCompletion( expect(result.completions).toContain("percent"); expectMetadata(result, { matchedPrefixLength: 13, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -363,7 +363,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["percent"], matchedPrefixLength: 13, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -436,7 +436,7 @@ describeForEachCompletion( expect(result.completions).toContain("play"); expectMetadata(result, { matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -523,7 +523,7 @@ describeForEachCompletion( expect(result.completions).toContain("deep"); expectMetadata(result, { matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -555,7 +555,7 @@ describeForEachCompletion( expect(result.completions).toContain("hello"); expectMetadata(result, { matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -762,7 +762,7 @@ describeForEachCompletion( expect(result.completions.sort()).toEqual(["play", "stop"]); expectMetadata(result, { matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", diff --git a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts index 9830ea909..2023aefda 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts @@ -129,7 +129,7 @@ describeForEachCompletion( it('forward: "play something played " — first keyword word + space, should offer "by"', () => { // Buggy result: // completions: ["played"], matchedPrefixLength: 22, - // separatorMode: "optional", afterWildcard: "all" + // separatorMode: "optionalSpace", afterWildcard: "all" // // Correct: "played " with trailing space — the first keyword // word was fully matched. Should offer "by" at @@ -143,7 +143,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 22, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -166,7 +166,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 18, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index 8879d9e9e..04f69e9fb 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -20,7 +20,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -33,7 +33,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -65,7 +65,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -131,7 +131,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -173,7 +173,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -291,7 +291,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -334,7 +334,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["再生"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -348,7 +348,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -361,7 +361,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -375,7 +375,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -395,7 +395,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["再生"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -410,7 +410,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -430,7 +430,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -442,7 +442,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["停止"], matchedPrefixLength: 8, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -465,7 +465,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -488,7 +488,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -525,12 +525,12 @@ describeForEachCompletion( ].join("\n"); const grammar = loadGrammarRules("test.grammar", g); - it("reports optional separatorMode when spacing=optional", () => { + it("reports optionalSpacePunctuation separatorMode when spacing=optional", () => { const result = matchGrammarCompletion(grammar, "play"); expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1000,7 +1000,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1020,7 +1020,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1077,7 +1077,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1166,7 +1166,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["songs"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1213,7 +1213,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["songs"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1389,7 +1389,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1407,7 +1407,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1519,7 +1519,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["停止"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1539,7 +1539,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1590,7 +1590,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1610,7 +1610,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1632,7 +1632,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1734,7 +1734,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1756,7 +1756,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1775,7 +1775,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1796,7 +1796,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1834,7 +1834,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2031,7 +2031,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2189,7 +2189,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2458,7 +2458,7 @@ describeForEachCompletion( expectMetadata(backward, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -3547,7 +3547,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -3564,7 +3564,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", diff --git a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts index f7e12a5da..f83f78b41 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts @@ -193,7 +193,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -210,7 +210,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["end"], matchedPrefixLength: 8, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -245,7 +245,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["baz"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -259,7 +259,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["baz"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -308,7 +308,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["baz"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -390,8 +390,8 @@ describeForEachCompletion( // Section 8: Mixed modes with alternation // // Two alternatives with different spacing modes but no - // spacing=none rule. "required" and "optional" are NOT a - // true separator conflict — "optional" is compatible with + // spacing=none rule. "required" and "optionalSpace" are NOT a + // true separator conflict — "optionalSpace" is compatible with // both trailing-separator states. The result is a normal // merge with the strongest separator mode winning. // ================================================================ @@ -399,7 +399,7 @@ describeForEachCompletion( describe("alternation with different spacing modes", () => { const g = ` [spacing=required] = hello world -> "required"; - [spacing=optional] = hello world -> "optional"; + [spacing=optional] = hello world -> "optionalSpace"; = $(x:) -> x | $(x:) -> x; `; const grammar = loadGrammarRules("test.grammar", g); @@ -474,11 +474,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "ab "); // Trailing space → drop "none", keep "spacePunctuation" // P advances from 2 to 3 (past the space). - // separatorMode is "optional" since sep is consumed. + // separatorMode is "optionalSpace" since sep is consumed. expectMetadata(result, { completions: ["cd"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -505,14 +505,14 @@ describeForEachCompletion( it("no trailing separator: keeps none + optional", () => { const result = matchGrammarCompletion(grammar, "ab"); - // Conflict: NoneRule → "none", OptRule → "optional", + // Conflict: NoneRule → "none", OptRule → "optionalSpacePunctuation", // ReqRule → "spacePunctuation" // No trailing sep → drop spacePunctuation - // Keep "none" + "optional" → merge to "optional" + // Keep "none" + "optionalSpacePunctuation" → merge to "optionalSpacePunctuation" expectMetadata(result, { completions: ["cd"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -522,14 +522,14 @@ describeForEachCompletion( it("trailing separator: keeps optional + required, drops none, P advanced", () => { const result = matchGrammarCompletion(grammar, "ab "); - // Trailing space → drop "none", keep "spacePunctuation" + "optional" + // Trailing space → drop "none", keep "spacePunctuation" + "optionalSpacePunctuation" // P advances to 3 (past space). - // Both spacePunctuation and optional merge to "optional" - // (sep already consumed into P). + // Both spacePunctuation and optionalSpacePunctuation merge to + // "optionalSpace" (sep already consumed into P). expectMetadata(result, { completions: ["cd"], matchedPrefixLength: 3, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -575,12 +575,12 @@ describeForEachCompletion( `; const grammar = loadGrammarRules("test.grammar", g); - it("no conflict: optional, closedSet true", () => { + it("no conflict: optionalSpacePunctuation, closedSet true", () => { const result = matchGrammarCompletion(grammar, "hello"); expectMetadata(result, { completions: ["world"], matchedPrefixLength: 5, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -644,7 +644,7 @@ describeForEachCompletion( // Backward reconsiders the last matched word "ab". // Without a second rule pushing maxPrefixLength higher, // the backed-up P=0 candidate wins. - // separatorMode is "optional" because at P=0 the + // separatorMode is "optionalSpace" because at P=0 the // separator between cursor and completion is governed // by the parent Start rule (auto spacing), not // NoneRule's internal spacing. @@ -657,7 +657,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -686,7 +686,7 @@ describeForEachCompletion( // "ab" to P=0. No second rule exists to establish a // higher maxPrefixLength, so the shadow candidate // (consumedLength=2) is NOT flushed. P=0 wins. - // separatorMode is "optional" (same as above — parent + // separatorMode is "optionalSpace" (same as above — parent // Start rule's auto spacing at P=0). const result = matchGrammarCompletion( grammar, @@ -697,7 +697,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -875,7 +875,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -907,7 +907,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["gh"], matchedPrefixLength: 4, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -920,7 +920,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["cd", "ef"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -943,7 +943,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ef", "cd"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -978,7 +978,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1cd", "1ef"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1016,7 +1016,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1ef", "1cd"], matchedPrefixLength: 2, - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: true, directionSensitive: true, afterWildcard: "none", diff --git a/ts/packages/actionGrammar/test/testUtils.ts b/ts/packages/actionGrammar/test/testUtils.ts index d6decd87e..da997dc8c 100644 --- a/ts/packages/actionGrammar/test/testUtils.ts +++ b/ts/packages/actionGrammar/test/testUtils.ts @@ -547,7 +547,8 @@ export function expectMetadata( separatorMode?: | "space" | "spacePunctuation" - | "optional" + | "optionalSpacePunctuation" + | "optionalSpace" | "none" | undefined; closedSet?: boolean; diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 048e0febd..eb9cf52a2 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -76,12 +76,24 @@ export type CommandDescriptors = // "spacePunctuation" — whitespace or Unicode punctuation ([\s\p{P}]) // required. Used by the grammar matcher for // Latin-script completions. -// "optional" — separator accepted but not required; menu shown -// immediately. Used for CJK / mixed-script -// grammar completions. +// "optionalSpacePunctuation" — separator accepted but not required; +// when present, both whitespace and Unicode +// punctuation are valid separators. Used by +// the grammar matcher for [spacing=optional] +// annotated rules. +// "optionalSpace" — separator accepted but not required; when +// present, only whitespace is treated as a +// separator. Used for CJK / mixed-script +// grammar completions and consumed-separator +// overrides. // "none" — no separator at all; menu shown immediately. // Used for [spacing=none] grammars. -export type SeparatorMode = "space" | "spacePunctuation" | "optional" | "none"; +export type SeparatorMode = + | "space" + | "spacePunctuation" + | "optionalSpacePunctuation" + | "optionalSpace" + | "none"; // Indicates the user's editing direction, provided by the host. // "forward" — the user is moving ahead (appending characters, diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 83c35b48b..0b2eb69bf 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -14,7 +14,8 @@ import { // Merge two SeparatorMode values — the mode requiring the strongest // separator wins (i.e. the mode that demands the most from the user). -// Priority: "space" > "spacePunctuation" > "optional" > "none" > undefined. +// Priority: "space" > "spacePunctuation" > "optionalSpacePunctuation" +// > "optionalSpace" > "none" > undefined. // Architecture: docs/architecture/completion.md — §3 Agent SDK export function mergeSeparatorMode( a: SeparatorMode | undefined, @@ -23,9 +24,10 @@ export function mergeSeparatorMode( if (a === undefined) return b; if (b === undefined) return a; const order: Record = { - space: 3, - spacePunctuation: 2, - optional: 1, + space: 4, + spacePunctuation: 3, + optionalSpacePunctuation: 2, + optionalSpace: 1, none: 0, }; return order[a] >= order[b] ? a : b; diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 2e0d99dd2..b36b0ff56 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -507,10 +507,10 @@ export class GrammarStoreImpl implements GrammarStore { // backspace in a multi-separator run produces // a distinct anchor for re-fetch. Remaining // separators are stripped by the shell's - // "optional" mode handling, keeping the menu + // "optionalSpace" mode handling, keeping the menu // visible with a clean trie prefix. matchedPrefixLength += 1; - separatorMode = "optional"; + separatorMode = "optionalSpace"; } // Force afterWildcard "all" → "some" so shell // doesn't use "slide" noMatchPolicy. diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 889ac3b87..4141d316b 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -621,7 +621,7 @@ export class ConstructionCache { ); separatorMode = mergeSeparatorMode( separatorMode, - needsSep ? "spacePunctuation" : "optional", + needsSep ? "spacePunctuation" : "optionalSpace", ); } } @@ -664,7 +664,7 @@ export class ConstructionCache { ); separatorMode = mergeSeparatorMode( separatorMode, - needsSep ? "spacePunctuation" : "optional", + needsSep ? "spacePunctuation" : "optionalSpace", ); } closedSet = false; @@ -675,7 +675,7 @@ export class ConstructionCache { // Advance past trailing separators so that the reported prefix // length includes any trailing whitespace the user typed. - // When advancing, demote separatorMode to "optional" — the + // When advancing, demote separatorMode to "optionalSpace" — the // trailing space is already consumed. // // Skip advancement when backward backed up: the backed-up @@ -686,7 +686,7 @@ export class ConstructionCache { const trailing = input.substring(maxPrefixLength); if (/^[\s\p{P}]+$/u.test(trailing)) { maxPrefixLength = input.length; - separatorMode = "optional"; + separatorMode = "optionalSpace"; } } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index 95890c4e1..8ccc28cf2 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -208,8 +208,8 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); // The matcher consumes "play" (4 chars). Trailing space - // is consumed, so separatorMode is demoted to "optional". - expect(result!.separatorMode).toBe("optional"); + // is consumed, so separatorMode is demoted to "optionalSpace". + expect(result!.separatorMode).toBe("optionalSpace"); }); it("returns spacePunctuation between adjacent word characters", () => { @@ -251,7 +251,7 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("hey! ", defaultOptions); if (result && result.completions.length > 0) { // ' ' is not a word char → optional - expect(result!.separatorMode).toBe("optional"); + expect(result!.separatorMode).toBe("optionalSpace"); } }); @@ -455,9 +455,9 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); expect(result!.completions).toEqual(["song"]); // Trailing space consumed → matchedPrefixLength advances to 5, - // separatorMode demoted to "optional". + // separatorMode demoted to "optionalSpace". expect(result!.matchedPrefixLength).toBe(5); - expect(result!.separatorMode).toBe("optional"); + expect(result!.separatorMode).toBe("optionalSpace"); }); it("prefix 'play s' — partial intra-part on second part, returns completions", () => { diff --git a/ts/packages/cache/test/crossGrammarConflict.spec.ts b/ts/packages/cache/test/crossGrammarConflict.spec.ts index a66974e75..62b2f1928 100644 --- a/ts/packages/cache/test/crossGrammarConflict.spec.ts +++ b/ts/packages/cache/test/crossGrammarConflict.spec.ts @@ -74,8 +74,8 @@ describe("Cross-grammar separator-mode conflict filtering", () => { expect(result).toBeDefined(); expect(result!.completions).toContain("cd"); expect(result!.closedSet).toBe(false); - // P advanced past the separator → separatorMode overridden to "optional". - expect(result!.separatorMode).toBe("optional"); + // P advanced past the separator → separatorMode overridden to "optionalSpace". + expect(result!.separatorMode).toBe("optionalSpace"); // matchedPrefixLength advanced by 1 past the trailing separator. expect(result!.matchedPrefixLength).toBe(3); }); diff --git a/ts/packages/cache/test/mergeCompletionResults.spec.ts b/ts/packages/cache/test/mergeCompletionResults.spec.ts index 214bdfc12..1d32181f2 100644 --- a/ts/packages/cache/test/mergeCompletionResults.spec.ts +++ b/ts/packages/cache/test/mergeCompletionResults.spec.ts @@ -244,7 +244,7 @@ describe("mergeCompletionResults", () => { }; const second: CompletionResult = { completions: ["b"], - separatorMode: "optional", + separatorMode: "optionalSpace", }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.separatorMode).toBe("spacePunctuation"); diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index b5a328cfc..8c3fff555 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -82,7 +82,7 @@ function detectPendingFlag( // whitespace acts as a commit signal: the token before it is // considered committed and the whitespace itself is consumed, so // startIndex should include it and separatorMode should be -// "optional" (no additional separator needed). +// "optionalSpace" (no additional separator needed). function hasWhitespaceBefore(text: string, index: number): boolean { return index > 0 && /\s/.test(text[index - 1]); } @@ -192,7 +192,7 @@ type ParameterCompletionResult = CommandCompletionResult; // ["true","false"] completions for this flag. // separatorMode — when set, the caller should use this as the // base separator mode (before merging with -// agent-reported modes). "optional" when +// agent-reported modes). "optionalSpace" when // trailing whitespace was consumed by startIndex. type CompletionTarget = { completionNames: string[]; @@ -226,7 +226,7 @@ function resolveCompletionTarget( includeFlags: true, booleanFlagName, separatorMode: hasWhitespaceBefore(input, remainderIndex) - ? "optional" + ? "optionalSpace" : undefined, directionSensitive: false, }; @@ -259,7 +259,7 @@ function resolveCompletionTarget( includeFlags: false, booleanFlagName: undefined, separatorMode: hasWhitespaceBefore(input, startIndex) - ? "optional" + ? "optionalSpace" : undefined, directionSensitive: false, }; @@ -291,7 +291,7 @@ function resolveCompletionTarget( includeFlags: true, booleanFlagName, separatorMode: hasWhitespaceBefore(input, flagTokenStart) - ? "optional" + ? "optionalSpace" : undefined, directionSensitive: true, }; @@ -300,7 +300,7 @@ function resolveCompletionTarget( // ── Spec case 2b: last token committed, complete next ─────── // startIndex is the raw position — includes any trailing // whitespace that the user typed. When trailing whitespace is - // present, separatorMode becomes "optional" because the + // present, separatorMode becomes "optionalSpace" because the // whitespace is already consumed. if (pendingFlag !== undefined) { // Flag awaiting a value — either the user moved forward or @@ -311,7 +311,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: false, booleanFlagName: undefined, - separatorMode: trailingWhitespace ? "optional" : undefined, + separatorMode: trailingWhitespace ? "optionalSpace" : undefined, directionSensitive: !trailingWhitespace, }; } @@ -321,7 +321,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: trailingWhitespace ? "optional" : undefined, + separatorMode: trailingWhitespace ? "optionalSpace" : undefined, directionSensitive: false, }; } @@ -358,7 +358,7 @@ function resolveCompletionTarget( // at the *end* of the consumed input (including any trailing // whitespace) and offer completions for the next parameters. // When trailing whitespace is present, separatorMode is -// "optional" because the whitespace is already consumed. +// "optionalSpace" because the whitespace is already consumed. // // ── Exceptions to case 2a ──────────────────────────────────────────────── // @@ -668,7 +668,7 @@ export async function getCommandCompletion( // Collect completions and track separatorMode across all sources. // When trailing whitespace was consumed *and* nothing follows - // (suffix is empty), separatorMode starts at "optional" — the + // (suffix is empty), separatorMode starts at "optionalSpace" — the // space is already part of the anchor so no additional separator // is needed. When a suffix exists (e.g. "--off"), the space // before it is structural, not trailing. @@ -676,7 +676,7 @@ export async function getCommandCompletion( let separatorMode: SeparatorMode | undefined = result.suffix.length === 0 && hasWhitespaceBefore(input, commandConsumedLength) - ? "optional" + ? "optionalSpace" : undefined; let closedSet = true; // Track whether direction influenced the result. When false, @@ -768,7 +768,7 @@ export async function getCommandCompletion( result.parsedAppAgentName !== undefined || result.commands.length > 0 ? "space" - : "optional", + : "optionalSpace", ); } else { // Both table and descriptor are undefined — the agent @@ -791,7 +791,7 @@ export async function getCommandCompletion( .getAppAgentNames() .filter((name) => context.agents.isCommandEnabled(name)), }); - separatorMode = mergeSeparatorMode(separatorMode, "optional"); + separatorMode = mergeSeparatorMode(separatorMode, "optionalSpace"); } if (startIndex === 0) { @@ -802,7 +802,7 @@ export async function getCommandCompletion( }); // The first token doesn't require separator before it - separatorMode = "optional"; + separatorMode = "optionalSpace"; } const completionResult: CommandCompletionResult = { startIndex, diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 8eb68e5f7..632b6edc6 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -57,7 +57,7 @@ function grammarCompletion(token: string): CompletionGroups { }, ], matchedPrefixLength: quoteOffset + 2, - separatorMode: "optional", + separatorMode: "optionalSpace", }; } // No prefix matched — offer initial completions. @@ -828,7 +828,7 @@ describe("Command Completion - startIndex", () => { expect(result).toBeDefined(); // Top-level completions (agent names, system subcommands) // follow '@' — space is accepted but not required. - expect(result!.separatorMode).toBe("optional"); + expect(result!.separatorMode).toBe("optionalSpace"); // Agent names are offered when no agent was recognized, // independent of which branch (descriptor/table/neither) // produced the subcommand completions. @@ -849,9 +849,9 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // Partial parameter token — only parameter completions returned, - // no subcommand group. separatorMode set to "optional" + // no subcommand group. separatorMode set to "optionalSpace" // due to trailing space advancement. - expect(result!.separatorMode).toBe("optional"); + expect(result!.separatorMode).toBe("optionalSpace"); }); it("returns no separatorMode for partial unmatched token consumed as param", async () => { diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 177c6eb60..353d79368 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -89,10 +89,10 @@ function computeNoMatchPolicy( // "slide" → wildcard boundary, slide anchor forward // - The anchor is never advanced after a result is received (except // when noMatchPolicy is "slide", which slides the anchor forward). -// When `separatorMode` requires a separator, or is "optional" -// (set by conflict filtering after consuming one separator into P), -// leading separator characters in the raw prefix are stripped before -// being passed to the menu, so the trie still matches. +// When `separatorMode` requires a separator, or is "optionalSpace" / +// "optionalSpacePunctuation", leading separator characters in the +// raw prefix are stripped before being passed to the menu, so the +// trie still matches. // // Architecture: docs/architecture/completion.md — §5 Shell — Completion Session // This class has no DOM dependencies and is fully unit-testable with Jest. @@ -208,10 +208,7 @@ export class PartialCompletionSession { return undefined; } } - // Strip leading separators for all modes except "none". - return sepMode !== "none" - ? stripLeadingSeparator(rawPrefix, sepMode) - : rawPrefix; + return stripLeadingSeparator(rawPrefix, sepMode); } // Decides whether the current session can service `input` without a new @@ -328,7 +325,7 @@ export class PartialCompletionSession { // satisfy the separatorMode constraint. // "space": whitespace required // "spacePunctuation": whitespace or Unicode punctuation required - // "optional"/"none": no separator needed, fall through to SHOW + // "optionalSpacePunctuation"/"optionalSpace"/"none": no separator needed, fall through to SHOW // // Three sub-cases when a separator IS required: // "" — separator not typed yet: HIDE+KEEP (separator may still arrive) @@ -388,12 +385,12 @@ export class PartialCompletionSession { // SHOW — strip the leading separator (if any) before passing to the // menu trie, so completions like "music" match prefix "" not " ". - // "optional" mode: separator is not required, but when present it + // "optionalSpace" mode: separator is not required, but when present it // should be stripped so the trie sees clean completion text. - const completionPrefix = - needsSep || sepMode === "optional" - ? stripLeadingSeparator(rawPrefix, sepMode) - : rawPrefix; + // "optionalSpacePunctuation" mode: same as "optionalSpace", but also + // strips leading punctuation (for [spacing=optional] grammars + // where punctuation is a valid separator). + const completionPrefix = stripLeadingSeparator(rawPrefix, sepMode); const position = getPosition(completionPrefix); if (position !== undefined) { @@ -580,14 +577,22 @@ function separatorRegex(mode: SeparatorMode): RegExp { } // Strip leading separator characters from rawPrefix. -// For "space" and "optional" modes, only whitespace is stripped. -// ("optional" uses whitespace-only because the consumed separator -// in conflict filtering was a whitespace character.) -// For "spacePunctuation" mode, leading whitespace and punctuation are stripped. +// For "space" and "optionalSpace" modes, only whitespace is stripped. +// ("optionalSpace" uses whitespace-only because it covers CJK/digit/mixed +// contexts where only whitespace is meaningful as a separator.) +// For "spacePunctuation" and "optionalSpacePunctuation" modes, leading +// whitespace and punctuation are stripped. function stripLeadingSeparator(rawPrefix: string, mode: SeparatorMode): string { - return mode === "space" || mode === "optional" - ? rawPrefix.trimStart() - : rawPrefix.replace(/^[\s\p{P}]+/u, ""); + switch (mode) { + case "none": + return rawPrefix; + case "space": + case "optionalSpace": + return rawPrefix.trimStart(); + case "spacePunctuation": + case "optionalSpacePunctuation": + return rawPrefix.replace(/^[\s\p{P}]+/u, ""); + } } // Convert backend CompletionGroups into flat SearchMenuItems, diff --git a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts index ac1217095..0d34a19db 100644 --- a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts +++ b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts @@ -1142,7 +1142,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // Re-fetch at "ab " returns: completions=["cd"], // matchedPrefixLength=3 (P advanced past one separator), - // separatorMode="optional". + // separatorMode="optionalSpace". expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ @@ -1182,7 +1182,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // Double space: anchor is "ab " (P=3), rawPrefix=" " (second space). - // separatorMode="optional" → needsSep=false, but "optional" + // separatorMode="optionalSpace" → needsSep=false, but "optionalSpace" // still strips leading whitespace → completionPrefix="". // Empty prefix matches all completions → menu shows. session.update("ab ", getPos); @@ -1217,7 +1217,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("ab ", getPos); await flush(); - // At anchor "ab " (P=3), separatorMode="optional", + // At anchor "ab " (P=3), separatorMode="optionalSpace", // rawPrefix="c" → trie prefix "c" → matches "cd". session.update("ab c", getPos); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts index a287dcec9..c469b9ab1 100644 --- a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -155,13 +155,13 @@ describe("PartialCompletionSession — separatorMode: spacePunctuation", () => { }); }); -// ── separatorMode: "optional" ───────────────────────────────────────────────── +// ── separatorMode: "optionalSpace" ───────────────────────────────────────────────── describe("PartialCompletionSession — separatorMode: optional", () => { test("completions shown immediately without separator", async () => { const menu = makeMenu(); const result = makeCompletionResult(["music"], 4, { - separatorMode: "optional", + separatorMode: "optionalSpace", }); const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -169,7 +169,7 @@ describe("PartialCompletionSession — separatorMode: optional", () => { session.update("play", getPos); await Promise.resolve(); - // "optional" does not require a separator — menu shown immediately + // "optionalSpace" does not require a separator — menu shown immediately // rawPrefix="" → updatePrefix("", ...) expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); }); @@ -177,7 +177,7 @@ describe("PartialCompletionSession — separatorMode: optional", () => { test("typing after anchor filters within session", async () => { const menu = makeMenu(); const result = makeCompletionResult(["music", "movie"], 4, { - separatorMode: "optional", + separatorMode: "optionalSpace", }); const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -190,6 +190,129 @@ describe("PartialCompletionSession — separatorMode: optional", () => { expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); + + test("whitespace stripped but punctuation kept in prefix", async () => { + const menu = makeMenu(); + const result = makeCompletionResult([".music"], 4, { + separatorMode: "optionalSpace", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // "optionalSpace" only strips whitespace — punctuation is preserved + session.update("play .mu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith(".mu", anyPosition); + }); +}); + +// ── separatorMode: "optionalSpacePunctuation" ───────────────────────────────── + +describe("PartialCompletionSession — separatorMode: optionalSpacePunctuation", () => { + test("completions shown immediately without separator (like optional)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Like "optionalSpace": no separator required — menu shown at anchor + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + }); + + test("typing after anchor filters within session", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music", "movie"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + session.update("playmu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("space stripped from prefix (like spacePunctuation)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + session.update("play mu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + }); + + test("punctuation stripped from prefix (unlike optional)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Key difference from "optionalSpace": punctuation IS stripped + session.update("play.mu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + }); + + test("mixed space+punctuation stripped from prefix", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Multiple leading separators (space + punctuation) all stripped + session.update("play .mu", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("mu", anyPosition); + }); + + test("no re-fetch when typing past anchor matches trie (separator not required)", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["music"], 4, { + separatorMode: "optionalSpacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Unlike "spacePunctuation", typing a letter after the anchor does + // NOT immediately invalidate the session — the separator is optional, + // so "playm" filters the trie for "m" which matches "music". + session.update("playm", getPos); + + expect(menu.updatePrefix).toHaveBeenCalledWith("m", anyPosition); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); }); // ── separatorMode + direction interactions ──────────────────────────────────── diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts index 00ca831fb..9678a0dd1 100644 --- a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts +++ b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts @@ -19,7 +19,7 @@ // // (B) startIndex AFTER the separator (e.g. "play "|"J") // → rawPrefix = "J", no leading separator -// → separatorMode = "none" or "optional" (no separator needed) +// → separatorMode = "none" or "optionalSpace" (no separator needed) // → trie filters on "J" directly // // (C) startIndex AFTER the separator + separatorMode still requires one @@ -228,15 +228,15 @@ describe("Pattern B — startIndex past separator (separatorMode=none)", () => { }); }); -// ── Pattern B variant: separatorMode="optional" ────────────────────────────── +// ── Pattern B variant: separatorMode="optionalSpace" ────────────────────────────── describe("Pattern B variant — startIndex past separator (separatorMode=optional)", () => { - // Same as Pattern B but with separatorMode="optional" — also does not + // Same as Pattern B but with separatorMode="optionalSpace" — also does not // require a separator. This covers CJK/mixed script grammars where // the grammar consumed through the space but tokens can abut. const result = makeCompletionResult(["Rock", "Jazz", "Blues"], 5, { - separatorMode: "optional", + separatorMode: "optionalSpace", closedSet: false, }); diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts index c08090ed7..3fd424d7a 100644 --- a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts +++ b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts @@ -431,7 +431,7 @@ describe("PartialCompletionSession — afterWildcard anchor sliding", () => { const result = makeCompletionResult(["next"], 8, { closedSet: true, afterWildcard: "all", - separatorMode: "optional", + separatorMode: "optionalSpace", }); const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); From d87cf5bf1ef00f14cf6201b1a1ed3c146897cebd Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Tue, 7 Apr 2026 18:28:10 -0700 Subject: [PATCH 02/10] Per-group separatorMode with advance-1 removal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move separatorMode from result-level to per-CompletionGroup across the entire completion pipeline (actionGrammar → cache → dispatcher → shell). Problem: A single separatorMode per completion result cannot express 'some completions need a separator, others don't'. When Latin (spacePunctuation) and CJK (optionalSpace) rules produce completions at the same position, the merged mode either hides CJK completions behind a required separator or lets users select completions that produce invalid input. The advance-1 workaround (filterSepConflicts) was complex, lossy, and created artificial re-fetch cycles. Fix: Each CompletionGroup now carries its own separatorMode. The shell partitions groups into SepLevels and shows/hides them independently based on the user's trailing separator state. actionGrammar: - Replace result-level completions/separatorMode with groups[] array of GrammarCompletionGroup (completions + separatorMode per group) - Remove filterSepConflicts, mergeSepMode, computeRangeNeedsSep, PROPERTY_SENTINEL_CHAR, and hasSepConflict/hasTrailingSep/ effectiveTrailingSep fields from CompletionContext - Introduce per-candidate separatorMode grouping in materializeCandidates agentSdk: - Move separatorMode from CompletionResult to CompletionGroup - Remove mergeSeparatorMode two-arg overload from commandHelpers cache: - grammarStore: emit CompletionGroup[] instead of flat completions[]; remove cross-grammar conflict detection/filtering - constructionCache: propagate per-group structure through merge dispatcher: - completion.ts: pass through per-group structure; remove result-level separatorMode handling - Remove separatorMode from DispatcherCompletionResult type shell (partialCompletionSession): - Replace single separatorMode with ItemPartition[] and SepLevel model - Two-anchor system: data-validity anchor + menuSepLevel for trie - loadLevel()/positionMenu()/stripAtLevel() for level-aware trie ops - Decision table in reuseSession: A (validity) → B (sep narrowing) → C (trie matching) → D (exhaustion cascade with widen) Tests updated across all layers; new separatorMode.spec.ts tests for per-group display logic. Adds plan-perGroupSeparatorMode.prompt.md. --- .../actionGrammar/src/grammarCompletion.ts | 363 ++------- ts/packages/actionGrammar/src/index.ts | 5 +- .../actionGrammar/src/nfaCompletion.ts | 9 +- ...mmarCompletionCategory3bLimitation.spec.ts | 173 ++--- ...grammarCompletionKeywordSpacePunct.spec.ts | 2 - .../grammarCompletionLongestMatch.spec.ts | 208 +++--- ...ammarCompletionMultiWordKeywordEOI.spec.ts | 14 +- .../grammarCompletionNestedWildcard.spec.ts | 40 +- .../grammarCompletionPrefixLength.spec.ts | 36 +- .../grammarCompletionSpacingNested.spec.ts | 160 ++-- .../actionGrammar/test/nfaDfaParity.spec.ts | 8 +- ts/packages/actionGrammar/test/testUtils.ts | 90 ++- ts/packages/agentSdk/src/command.ts | 5 +- .../agentSdk/src/helpers/commandHelpers.ts | 21 - .../serviceWorker/serviceWorkerRpcHandlers.ts | 11 +- ts/packages/cache/src/cache/grammarStore.ts | 119 +-- .../src/constructions/constructionCache.ts | 75 +- ts/packages/cache/test/completion.spec.ts | 139 ++-- .../cache/test/crossGrammarConflict.spec.ts | 48 +- .../cache/test/grammarIntegration.spec.ts | 10 +- .../cache/test/mergeCompletionResults.spec.ts | 474 +++++++++--- ts/packages/cli/src/commands/connect.ts | 11 +- ts/packages/cli/src/commands/interactive.ts | 13 +- .../dispatcher/src/command/completion.ts | 62 +- .../handlers/matchCommandHandler.ts | 1 - .../handlers/requestCommandHandler.ts | 1 - .../handlers/translateCommandHandler.ts | 1 - .../src/translation/requestCompletion.ts | 15 +- .../dispatcher/test/completion.spec.ts | 32 +- .../test/requestCompletionPropagation.spec.ts | 36 +- .../dispatcher/types/src/dispatcher.ts | 5 - .../shell/src/preload/electronTypes.ts | 4 +- .../renderer/src/partialCompletionSession.ts | 688 ++++++++++-------- .../test/partialCompletion/grammarE2E.spec.ts | 600 +++++++++++++-- .../shell/test/partialCompletion/helpers.ts | 36 +- .../test/partialCompletion/publicAPI.spec.ts | 13 +- .../resultProcessing.spec.ts | 11 +- .../partialCompletion/separatorMode.spec.ts | 334 ++++++++- 38 files changed, 2399 insertions(+), 1474 deletions(-) diff --git a/ts/packages/actionGrammar/src/grammarCompletion.ts b/ts/packages/actionGrammar/src/grammarCompletion.ts index 01c92954b..37ab2955e 100644 --- a/ts/packages/actionGrammar/src/grammarCompletion.ts +++ b/ts/packages/actionGrammar/src/grammarCompletion.ts @@ -12,7 +12,6 @@ import { separatorRegExpStr, requiresSeparator, candidateSeparatorMode, - mergeSeparatorMode, isBoundarySatisfied, nextNonSeparatorIndex, getWildcardStr, @@ -304,24 +303,16 @@ export type GrammarCompletionProperty = { export type AfterWildcard = "none" | "some" | "all"; export type GrammarCompletionResult = { - completions: string[]; + // Per-group completions, partitioned by separator mode. + // Each group carries its own separatorMode — the shell shows/hides + // groups based on the user's trailing separator state. + // When only a single mode is present, there is one group. + groups: GrammarCompletionGroup[]; properties?: GrammarCompletionProperty[] | undefined; // Number of characters from the input prefix that the grammar consumed // before the completion point. The shell uses this to determine where // to insert/filter completions. matchedPrefixLength?: number | undefined; - // What kind of separator is expected between the content at - // `matchedPrefixLength` and the completion text. This is a - // *completion-result* concept (SeparatorMode), derived from the - // per-rule *match-time* spacing rules (CompiledSpacingMode / - // spacingMode) but distinct from them. - // "spacePunctuation" — whitespace or punctuation required - // (Latin "y" → "m" requires a separator). - // "optionalSpace" — separator accepted but not required - // (CJK 再生 → 音楽 does not require a separator). - // "none" — no separator at all ([spacing=none] grammars). - // Omitted when no completions were generated. - separatorMode?: SeparatorMode | undefined; // True when `completions` is the closed set of valid // continuations after the matched prefix — if the user types // something not in the list, no further completions can exist @@ -367,6 +358,14 @@ export type GrammarCompletionResult = { afterWildcard: AfterWildcard; }; +// A completion group within a GrammarCompletionResult. +// Intentionally parallel to CompletionGroup in @typeagent/agent-sdk +// but defined here because actionGrammar has no dependency on agentSdk. +export type GrammarCompletionGroup = { + completions: string[]; + separatorMode: SeparatorMode; +}; + function getGrammarCompletionProperty( state: MatchState, valueId: number, @@ -626,14 +625,6 @@ type DeferredShadowCandidate = { candidate: FixedCandidate; }; -// Sentinel character for property candidates in separator mode -// computation. Property completions represent free-form entity -// slots (not literal keywords), so any word character works — -// when passed to `requiresSeparator()`, "a" is a word character -// (matches \w), so it causes `requiresSeparator` to return true -// for word-boundary spacing modes. -const PROPERTY_SENTINEL_CHAR = "a"; - // True when a separator character (whitespace or punctuation) exists // at the given position in the input. Used by both within-grammar // conflict filtering (filterSepConflicts) and cross-grammar conflict @@ -644,8 +635,7 @@ export function hasTrailingSeparator(input: string, position: number): boolean { // True when a separator is needed between the character at // `position - 1` in `input` and `firstCompletionChar` according -// to `spacingMode`. Shared by mergeSepMode, computeCandidateSeparatorMode, -// computeRangeNeedsSep, and (indirectly) filterSepConflicts. +// to `spacingMode`. Used by computeCandidateSeparatorMode. function computeNeedsSep( input: string, position: number, @@ -659,27 +649,7 @@ function computeNeedsSep( ); } -// Compute whether a separator is needed between the character at -// `position - 1` in `input` and `firstCompletionChar`, then merge -// the result into the running `current` separator mode. -function mergeSepMode( - input: string, - current: SeparatorMode | undefined, - position: number, - firstCompletionChar: string, - spacingMode: CompiledSpacingMode, -): SeparatorMode | undefined { - return mergeSeparatorMode( - current, - computeNeedsSep(input, position, firstCompletionChar, spacingMode), - spacingMode, - ); -} - // Compute a candidate's individual SeparatorMode at a given position. -// Uses the same (position, firstCompletionChar, spacingMode) logic as -// mergeSepMode, but returns the per-candidate mode instead of merging -// into the running aggregate. function computeCandidateSeparatorMode( input: string, position: number, @@ -748,19 +718,6 @@ type CompletionContext = { /** Shadow candidates from backward Cat 3b, deferred until * maxPrefixLength is finalized so the check is order-independent. */ deferredShadowCandidates: DeferredShadowCandidate[]; - /** True when fixedCandidates have a separator mode conflict - * (some require a separator, others reject it). - * Set by filterSepConflicts. Implies candidates were dropped. */ - hasSepConflict: boolean; - /** True when trailing separator characters follow maxPrefixLength - * during a separator conflict. Set by filterSepConflicts. */ - hasTrailingSep: boolean; - /** Effective trailing-sep state for conflict filtering. False for - * backward (treats separator as absent); equals hasTrailingSep - * for forward. Set by filterSepConflicts; used by - * computeRangeNeedsSep to avoid recomputing the trailing-separator - * check and backward-direction override for each range candidate. */ - effectiveTrailingSep: boolean; }; // Update maxPrefixLength. When it increases, all previously @@ -1225,9 +1182,6 @@ function collectCandidates( rangeCandidates: [], wildcardEoiDescriptors: [], deferredShadowCandidates: [], - hasSepConflict: false, - hasTrailingSep: false, - effectiveTrailingSep: false, }; // Seed the work-list with one MatchState per top-level grammar rule. @@ -1434,113 +1388,12 @@ function injectForwardEoiCandidates( } } -// --- Separator mode conflict detection and filtering --- -// -// When fixed candidates at the same maxPrefixLength come from -// rules with different spacing modes, some require a separator -// while others reject it. This function detects the conflict, -// removes the incompatible set based on the trailing separator -// state, advances P past the separator when needed, and records -// the conflict state on ctx for materializeCandidates to use -// during range candidate processing and output adjustment. -// -// Three-way compatibility: -// Trailing sep? spacePunctuation optional none -// No drop keep keep -// Yes keep keep drop -// -// When candidates are dropped, closedSet is forced to false (by -// materializeCandidates) so the shell re-fetches when the -// separator state changes. When trailing separator is present -// and candidates are filtered, maxPrefixLength is advanced past -// the separator so the shell's anchor diverges on backspace, -// triggering an automatic re-fetch. -function filterSepConflicts(ctx: CompletionContext): void { - const { input } = ctx; - // Snapshot the current candidates array. The filter below - // replaces ctx.fixedCandidates with a new array; keeping a - // local reference avoids confusion between old and new. - const originalCandidates = ctx.fixedCandidates; - - // Compute each candidate's separator mode once; reused by the - // filter pass below when a conflict is detected. - const modes: SeparatorMode[] = new Array(originalCandidates.length); - let hasRequiring = false; - let hasNoneMode = false; - for (let i = 0; i < originalCandidates.length; i++) { - const c = originalCandidates[i]; - const firstChar = - c.kind === "string" ? c.completionText[0] : PROPERTY_SENTINEL_CHAR; - const mode = computeCandidateSeparatorMode( - input, - ctx.maxPrefixLength, - firstChar, - c.spacingMode, - ); - modes[i] = mode; - if (isRequiringSepMode(mode)) { - hasRequiring = true; - } - if (mode === "none") { - hasNoneMode = true; - } - } - - // A true separator conflict occurs only when "none" mode - // (rejects separator) coexists with requiring modes (needs - // separator). "optionalSpace" is compatible with both states and - // does not participate in the conflict. - ctx.hasSepConflict = hasRequiring && hasNoneMode; - if (!ctx.hasSepConflict) return; - - ctx.hasTrailingSep = hasTrailingSeparator(input, ctx.maxPrefixLength); - - // Backward resolves the conflict as if there were no trailing - // separator: it will return mpl at the scan position (before the - // separator), so the separator is beyond its anchor and should not - // influence which candidates survive. Treating hasTrailingSep as - // false for backward makes its candidate set match what forward - // would compute on input[0..mpl] (no trailing sep), satisfying - // Invariant #3. - ctx.effectiveTrailingSep = - ctx.direction === "backward" ? false : ctx.hasTrailingSep; - - // Filter fixed candidates in place using the pre-computed - // modes, avoiding a second computeCandidateSeparatorMode call. - if (ctx.effectiveTrailingSep) { - // Trailing separator: drop "none" (rejects separator). - ctx.fixedCandidates = originalCandidates.filter( - (_, i) => modes[i] !== "none", - ); - // Advance P past exactly one separator so backspace triggers - // re-fetch. Only one character (not all consecutive - // separators) is consumed: each backspace in a multi-separator - // run produces a distinct anchor. Remaining separators are - // stripped by the shell's "optionalSpace" mode handling, keeping the - // menu visible with a clean trie prefix. - // - // Safe unconditionally: hasSepConflict is true (checked above), - // so both "none" and requiring candidates coexist — the filter - // above always drops at least one candidate. - ctx.maxPrefixLength += 1; - } else { - // No trailing separator (or backward): drop requiring modes. - // Backward does not advance: its anchor stays at the scan - // position (before the separator). This keeps - // backward.mpl < forward.mpl, satisfying Invariant #7. - ctx.fixedCandidates = originalCandidates.filter( - (_, i) => !isRequiringSepMode(modes[i]), - ); - } -} - -// --- Phase 2 (finalize): wildcard anchors, shadows, EOI, sep conflicts --- +// --- Phase 2 (finalize): wildcard anchors, shadows, EOI --- // // Resolves deferred wildcard-at-EOI descriptors, flushes shadow -// candidates, injects forward EOI candidates as fixedCandidates, -// and filters separator mode conflicts. After this function -// returns, fixedCandidates are pre-filtered, rangeCandidates are -// ready, and maxPrefixLength is finalized — ready for Phase 3 +// candidates, and injects forward EOI candidates as fixedCandidates. +// After this function returns, fixedCandidates and rangeCandidates +// are ready, and maxPrefixLength is finalized — ready for Phase 3 // (materializeCandidates). // // wildcardEoiDescriptors contains all wildcard-at-EOI string @@ -1560,8 +1413,6 @@ function filterSepConflicts(ctx: CompletionContext): void { // via injectForwardEoiCandidates. // 2. Shadow candidates are flushed (consumedLength must match // the now-settled maxPrefixLength). -// 3. Separator mode conflicts are filtered via -// filterSepConflicts. function finalizeCandidates(ctx: CompletionContext): void { const { input, @@ -1674,12 +1525,10 @@ function finalizeCandidates(ctx: CompletionContext): void { } } - // The three post-loop steps MUST run in this order: + // The two post-loop steps MUST run in this order: // 1. injectForwardEoiCandidates — may clear fixedCandidates // and advance maxPrefixLength (displace path). // 2. flushShadowCandidates — checks maxPrefixLength set by (1). - // 3. filterSepConflicts — operates on the final fixedCandidates - // populated by (1) and (2). // (Steps 1 and 2 are direction-exclusive — forward early-returns // in injectForwardEoiCandidates; shadows are only collected during // backward — so their internal order is interchangeable. Forward @@ -1697,38 +1546,6 @@ function finalizeCandidates(ctx: CompletionContext): void { // Flush deferred shadow candidates into fixedCandidates. flushShadowCandidates(ctx); - - // Filter separator mode conflicts among fixed candidates. - filterSepConflicts(ctx); -} - -// Compute needsSep for a range candidate and check whether it -// should be dropped due to a separator conflict. Returns a -// tri-state: "needsSep" (separator required, candidate survives), -// "noSep" (no separator needed, candidate survives), or "drop" -// (candidate filtered out by separator conflict). -function computeRangeNeedsSep( - ctx: CompletionContext, - firstCompletionChar: string, - spacingMode: CompiledSpacingMode, -): "needsSep" | "noSep" | "drop" { - const needsSep = computeNeedsSep( - ctx.input, - ctx.maxPrefixLength, - firstCompletionChar, - spacingMode, - ); - if (ctx.hasSepConflict) { - const mode = candidateSeparatorMode(needsSep, spacingMode); - if ( - ctx.effectiveTrailingSep - ? mode === "none" - : isRequiringSepMode(mode) - ) { - return "drop"; - } - } - return needsSep ? "needsSep" : "noSep"; } // --- Phase 3 (materialize): Convert candidates to final completions/properties --- @@ -1745,44 +1562,40 @@ function computeRangeNeedsSep( // expansion states of the same rule) can produce the same // completion text at the same maxPrefixLength. Showing // duplicates in the menu is unhelpful, so we deduplicate -// globally. +// globally within each separator mode group. function materializeCandidates( ctx: CompletionContext, ): GrammarCompletionResult { const { input, direction, fixedCandidates, rangeCandidates } = ctx; - const completions = new Set(); + + // Per-mode buckets for string completions (deduplicated). + const modeCompletions = new Map>(); const properties: GrammarCompletionProperty[] = []; - // Derive output fields from surviving fixed candidates — only - // candidates at the final maxPrefixLength contribute. The - // implicit reset when updateMaxPrefixLength clears fixedCandidates - // is automatic (no surviving candidate ⇒ default value). - // separatorMode, closedSet, and afterWildcard may be updated by - // range candidates and forward EOI candidates below. - let separatorMode: SeparatorMode | undefined; let closedSet = true; - // Two booleans that accumulate independently during candidate - // processing, then combine into the output AfterWildcard - // tri-state at the end. Order-independent: each candidate - // sets one flag without needing to know what came before. - // anyAfterWildcard: true if ANY candidate is after a wildcard - // hasNonWildcardCompletion: true if ANY STRING candidate is NOT - // after a wildcard - // Property completions don't affect hasNonWildcardCompletion — - // they don't go into the shell's trie (they are metadata for - // the dispatcher to request agent property values, gated by - // closedSet=false). - // - // A single tri-state can't replace these two booleans because - // candidateorder is arbitrary: both "none → wildcard → all" - // and "none → non-wildcard string → none" are valid prefixes, - // but the tri-state can't distinguish "none (empty)" from - // "none (has non-wildcard string)" when a wildcard arrives. let anyAfterWildcard = false; let hasNonWildcardCompletion = false; let partialKeywordBackup = false; + // Helper: add a string completion to its mode bucket. + function addCompletion(text: string, mode: SeparatorMode): void { + let bucket = modeCompletions.get(mode); + if (bucket === undefined) { + bucket = new Set(); + modeCompletions.set(mode, bucket); + } + bucket.add(text); + } + + // Helper: check global dedup across all mode buckets. + function hasCompletion(text: string): boolean { + for (const bucket of modeCompletions.values()) { + if (bucket.has(text)) return true; + } + return false; + } + for (const c of fixedCandidates) { if (c.isAfterWildcard) { anyAfterWildcard = true; @@ -1793,14 +1606,13 @@ function materializeCandidates( partialKeywordBackup = true; } if (c.kind === "string") { - completions.add(c.completionText); - separatorMode = mergeSepMode( + const mode = computeCandidateSeparatorMode( input, - separatorMode, ctx.maxPrefixLength, c.completionText[0], c.spacingMode, ); + addCompletion(c.completionText, mode); } else { const completionProperty = getGrammarCompletionProperty( c.state, @@ -1809,37 +1621,10 @@ function materializeCandidates( if (completionProperty !== undefined) { properties.push(completionProperty); closedSet = false; - separatorMode = mergeSepMode( - input, - separatorMode, - ctx.maxPrefixLength, - PROPERTY_SENTINEL_CHAR, - c.spacingMode, - ); } } } - // When P was advanced past trailing separator chars (trailing-sep - // conflict path), the separator is already consumed into P. - // Override separatorMode to "optionalSpace" — no *additional* separator - // is required between the (advanced) P and the completion text. - // mergeSepMode may have computed "spacePunctuation" because - // input[P-1] is a separator character, but that separator is the - // one we consumed, not a new requirement. - // - // Not applied for backward: backward does not advance P past the - // separator (see filterSepConflicts), so input[P-1] is still the - // last matched character (not a separator), and no override is needed. - if ( - ctx.hasTrailingSep && - ctx.hasSepConflict && - ctx.direction !== "backward" && - separatorMode !== undefined - ) { - separatorMode = "optionalSpace"; - } - // Range candidates replace the old two-pass backward retrigger // (which recursively called matchGrammarCompletion(forward) at // the backed-up position). Each range candidate records a @@ -1896,22 +1681,15 @@ function materializeCandidates( ); if ( partial !== undefined && - !completions.has(partial.remainingText) + !hasCompletion(partial.remainingText) ) { - const sepResult = computeRangeNeedsSep( - ctx, + const mode = computeCandidateSeparatorMode( + input, + ctx.maxPrefixLength, partial.remainingText[0], c.spacingMode, ); - if (sepResult === "drop") { - continue; - } - completions.add(partial.remainingText); - separatorMode = mergeSeparatorMode( - separatorMode, - sepResult === "needsSep", - c.spacingMode, - ); + addCompletion(partial.remainingText, mode); anyAfterWildcard = true; } } else { @@ -1920,20 +1698,7 @@ function materializeCandidates( c.valueId, ); if (completionProperty !== undefined) { - const sepResult = computeRangeNeedsSep( - ctx, - PROPERTY_SENTINEL_CHAR, - c.spacingMode, - ); - if (sepResult === "drop") { - continue; - } properties.push(completionProperty); - separatorMode = mergeSeparatorMode( - separatorMode, - sepResult === "needsSep", - c.spacingMode, - ); anyAfterWildcard = true; closedSet = false; } @@ -1941,13 +1706,6 @@ function materializeCandidates( } } - // If candidates were dropped due to separator conflict, - // force closedSet=false so the shell re-fetches when the - // separator state changes (typing/deleting a space). - if (ctx.hasSepConflict) { - closedSet = false; - } - // See the directionSensitive field comment on // GrammarCompletionResult for why P > 0 is correct. // minPrefixLength (caller-supplied search lower bound) is @@ -1957,26 +1715,25 @@ function materializeCandidates( // Combine the two accumulation booleans into the output // tri-state. Order-independent: each boolean was set by // any candidate that matched its condition. - // - // When candidates were dropped due to separator conflict - // filtering, force "all" → "some" to prevent the shell - // from using the "slide" noMatchPolicy (which would - // suppress re-fetch when the separator state changes). - const rawAfterWildcard: AfterWildcard = !anyAfterWildcard + const afterWildcard: AfterWildcard = !anyAfterWildcard ? "none" : hasNonWildcardCompletion ? "some" : "all"; - const afterWildcard: AfterWildcard = - ctx.hasSepConflict && rawAfterWildcard === "all" - ? "some" - : rawAfterWildcard; + + // Build per-mode groups. + const groups: GrammarCompletionGroup[] = []; + for (const [mode, bucket] of modeCompletions) { + groups.push({ + completions: [...bucket], + separatorMode: mode, + }); + } return { - completions: [...completions], + groups, properties, matchedPrefixLength: ctx.maxPrefixLength, - separatorMode, closedSet, directionSensitive, afterWildcard, @@ -1995,7 +1752,7 @@ export function matchGrammarCompletion( // Phase 1 (collect) const ctx = collectCandidates(grammar, input, minPrefixLength, direction); - // Phase 2 (finalize): wildcard anchors, shadows, EOI, sep conflicts + // Phase 2 (finalize): wildcard anchors, shadows, EOI finalizeCandidates(ctx); // Phase 3 (materialize): convert candidates to final completions/properties const result = materializeCandidates(ctx); diff --git a/ts/packages/actionGrammar/src/index.ts b/ts/packages/actionGrammar/src/index.ts index 9e8b92fe1..cf4995011 100644 --- a/ts/packages/actionGrammar/src/index.ts +++ b/ts/packages/actionGrammar/src/index.ts @@ -31,7 +31,10 @@ export { isRequiringSepMode, hasTrailingSeparator, } from "./grammarCompletion.js"; -export type { AfterWildcard } from "./grammarCompletion.js"; +export type { + AfterWildcard, + GrammarCompletionGroup, +} from "./grammarCompletion.js"; // Entity system export type { EntityValidator, EntityConverter } from "./entityRegistry.js"; diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index ba449c631..394520092 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -262,7 +262,7 @@ export function computeNFACompletions( if (reachableStates.length === 0) { debugCompletion(` → no reachable states, returning empty`); return { - completions: [], + groups: [], directionSensitive: false, afterWildcard: "none", }; @@ -288,7 +288,12 @@ export function computeNFACompletions( ); const result: GrammarCompletionResult = { - completions: uniqueCompletions, + groups: [ + { + completions: uniqueCompletions, + separatorMode: "space", + }, + ], directionSensitive: false, // TODO: The NFA path does not yet track wildcard-at-EOI states. // If NFA grammars gain wildcard support, this should be computed diff --git a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts index e58179f45..340c27b7b 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { loadGrammarRules } from "../src/grammarLoader.js"; -import { describeForEachCompletion } from "./testUtils.js"; +import { describeForEachCompletion, expectMetadata } from "./testUtils.js"; describeForEachCompletion( "Grammar Completion - all alternatives after longest match", @@ -24,54 +24,44 @@ describeForEachCompletion( it("without partial text: all alternatives are offered", () => { const result = matchGrammarCompletion(grammar, "play rock"); - expect(result.completions.sort()).toEqual([ - "hard", - "loud", - "music", - ]); - expect(result.matchedPrefixLength).toBe(9); + expectMetadata(result, { + completions: ["hard", "loud", "music"], + matchedPrefixLength: 9, + }); }); it("with trailing space: all alternatives are still offered", () => { const result = matchGrammarCompletion(grammar, "play rock "); - expect(result.completions.sort()).toEqual([ - "hard", - "loud", - "music", - ]); + expectMetadata(result, { + completions: ["hard", "loud", "music"], + }); }); it("partial text 'm': all alternatives still reported", () => { const result = matchGrammarCompletion(grammar, "play rock m"); // All three are reported; caller filters by "m". - expect(result.completions.sort()).toEqual([ - "hard", - "loud", - "music", - ]); - expect(result.matchedPrefixLength).toBe(9); + expectMetadata(result, { + completions: ["hard", "loud", "music"], + matchedPrefixLength: 9, + }); }); it("partial text 'h': all alternatives still reported", () => { const result = matchGrammarCompletion(grammar, "play rock h"); - expect(result.completions.sort()).toEqual([ - "hard", - "loud", - "music", - ]); - expect(result.matchedPrefixLength).toBe(9); + expectMetadata(result, { + completions: ["hard", "loud", "music"], + matchedPrefixLength: 9, + }); }); it("non-matching text 'x': all alternatives still reported", () => { const result = matchGrammarCompletion(grammar, "play rock x"); // All alternatives are reported even though "x" doesn't // prefix-match any of them; the caller filters. - expect(result.completions.sort()).toEqual([ - "hard", - "loud", - "music", - ]); - expect(result.matchedPrefixLength).toBe(9); + expectMetadata(result, { + completions: ["hard", "loud", "music"], + matchedPrefixLength: 9, + }); }); }); @@ -83,34 +73,25 @@ describeForEachCompletion( it("all directions offered without partial text", () => { const result = matchGrammarCompletion(grammar, "go"); - expect(result.completions.sort()).toEqual([ - "east", - "north", - "south", - "west", - ]); + expectMetadata(result, { + completions: ["east", "north", "south", "west"], + }); }); it("'n' trailing: all directions still offered", () => { const result = matchGrammarCompletion(grammar, "go n"); - expect(result.completions.sort()).toEqual([ - "east", - "north", - "south", - "west", - ]); - expect(result.matchedPrefixLength).toBe(2); + expectMetadata(result, { + completions: ["east", "north", "south", "west"], + matchedPrefixLength: 2, + }); }); it("'z' trailing: all directions still offered", () => { const result = matchGrammarCompletion(grammar, "go z"); - expect(result.completions.sort()).toEqual([ - "east", - "north", - "south", - "west", - ]); - expect(result.matchedPrefixLength).toBe(2); + expectMetadata(result, { + completions: ["east", "north", "south", "west"], + matchedPrefixLength: 2, + }); }); }); @@ -126,22 +107,18 @@ describeForEachCompletion( it("'open f': all alternatives offered", () => { const result = matchGrammarCompletion(grammar, "open f"); - expect(result.completions.sort()).toEqual([ - "file", - "finder", - "folder", - ]); + expectMetadata(result, { + completions: ["file", "finder", "folder"], + }); }); it("'open fi': all alternatives still offered", () => { const result = matchGrammarCompletion(grammar, "open fi"); // "folder" is now correctly reported alongside file/finder. - expect(result.completions.sort()).toEqual([ - "file", - "finder", - "folder", - ]); - expect(result.matchedPrefixLength).toBe(4); + expectMetadata(result, { + completions: ["file", "finder", "folder"], + matchedPrefixLength: 4, + }); }); }); @@ -158,13 +135,17 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "xyz"); // Nothing consumed; the first string part is offered // unconditionally. The caller filters by trailing text. - expect(result.completions).toEqual(["play"]); - expect(result.matchedPrefixLength).toBe(0); + expectMetadata(result, { + completions: ["play"], + matchedPrefixLength: 0, + }); }); it("partial prefix at start still works", () => { const result = matchGrammarCompletion(grammar, "pl"); - expect(result.completions).toContain("play"); + expectMetadata(result, { + completions: ["play"], + }); }); }); @@ -188,24 +169,22 @@ describeForEachCompletion( // Last consumed char: "y" (Latin), first completion char: "m" (Latin) // → separator needed. const result = matchGrammarCompletion(grammar, "play x"); - expect(result.completions.sort()).toEqual([ - "midi", - "music", - ]); - expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("spacePunctuation"); + expectMetadata(result, { + completions: ["midi", "music"], + matchedPrefixLength: 4, + separatorMode: "spacePunctuation", + }); }); it("reports separatorMode with partial-match trailing text", () => { // "play mu" → consumed "play" (4 chars), trailing "mu". // Same boundary: "y" → "m" → separator needed. const result = matchGrammarCompletion(grammar, "play mu"); - expect(result.completions.sort()).toEqual([ - "midi", - "music", - ]); - expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("spacePunctuation"); + expectMetadata(result, { + completions: ["midi", "music"], + matchedPrefixLength: 4, + separatorMode: "spacePunctuation", + }); }); }); @@ -223,9 +202,11 @@ describeForEachCompletion( // Last consumed char: "生" (CJK), first completion: "音" (CJK) // → separator optional in auto mode. const result = matchGrammarCompletion(grammar, "再生x"); - expect(result.completions.sort()).toEqual(["映画", "音楽"]); - expect(result.matchedPrefixLength).toBe(2); - expect(result.separatorMode).toBe("optionalSpace"); + expectMetadata(result, { + completions: ["映画", "音楽"], + matchedPrefixLength: 2, + separatorMode: "optionalSpace", + }); }); }); @@ -240,9 +221,11 @@ describeForEachCompletion( // "xyz" → consumed 0 chars, offers "play" at prefixLength=0. // No last consumed char → no separator check. const result = matchGrammarCompletion(grammar, "xyz"); - expect(result.completions).toEqual(["play"]); - expect(result.matchedPrefixLength).toBe(0); - expect(result.separatorMode).toBe("optionalSpace"); + expectMetadata(result, { + completions: ["play"], + matchedPrefixLength: 0, + separatorMode: "optionalSpace", + }); }); }); @@ -256,9 +239,11 @@ describeForEachCompletion( it("reports separatorMode for spacing=required with trailing text", () => { const result = matchGrammarCompletion(grammar, "play x"); - expect(result.completions).toEqual(["music"]); - expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("spacePunctuation"); + expectMetadata(result, { + completions: ["music"], + matchedPrefixLength: 4, + separatorMode: "spacePunctuation", + }); }); }); @@ -272,11 +257,11 @@ describeForEachCompletion( it("reports optionalSpacePunctuation separatorMode for spacing=optional", () => { const result = matchGrammarCompletion(grammar, "play x"); - expect(result.completions).toEqual(["music"]); - expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe( - "optionalSpacePunctuation", - ); + expectMetadata(result, { + completions: ["music"], + matchedPrefixLength: 4, + separatorMode: "optionalSpacePunctuation", + }); }); }); @@ -292,9 +277,11 @@ describeForEachCompletion( // Last consumed: "y" (Latin), completion: "音" (CJK) // → different scripts, separator optional in auto mode. const result = matchGrammarCompletion(grammar, "play x"); - expect(result.completions).toEqual(["音楽"]); - expect(result.matchedPrefixLength).toBe(4); - expect(result.separatorMode).toBe("optionalSpace"); + expectMetadata(result, { + completions: ["音楽"], + matchedPrefixLength: 4, + separatorMode: "optionalSpace", + }); }); }); }); diff --git a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts index 927553c73..36ba0505f 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts @@ -1437,7 +1437,6 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, ""); expectMetadata(result, { completions: ["hello,", "hello."], - sortCompletions: true, matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -2246,7 +2245,6 @@ describeForEachCompletion( // requiresSeparator("y", "h" or "s", auto) → both Latin → "spacePunctuation" expectMetadata(result, { completions: ["hello,", "shuffle"], - sortCompletions: true, matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 5e8ef0015..5749da259 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -19,8 +19,8 @@ describeForEachCompletion( it("completes first part for empty input", () => { const result = matchGrammarCompletion(grammar, ""); - expect(result.completions).toContain("first"); expectMetadata(result, { + completions: ["first"], matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -32,8 +32,8 @@ describeForEachCompletion( it("completes second part after first matched", () => { const result = matchGrammarCompletion(grammar, "first"); - expect(result.completions).toContain("second"); expectMetadata(result, { + completions: ["second"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -45,8 +45,8 @@ describeForEachCompletion( it("completes second part after first matched with space", () => { const result = matchGrammarCompletion(grammar, "first "); - expect(result.completions).toContain("second"); expectMetadata(result, { + completions: ["second"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -58,8 +58,8 @@ describeForEachCompletion( it("completes third part after first two matched", () => { const result = matchGrammarCompletion(grammar, "first second"); - expect(result.completions).toContain("third"); expectMetadata(result, { + completions: ["third"], matchedPrefixLength: 12, separatorMode: "spacePunctuation", closedSet: true, @@ -71,8 +71,8 @@ describeForEachCompletion( it("completes third part after first two matched with space", () => { const result = matchGrammarCompletion(grammar, "first second "); - expect(result.completions).toContain("third"); expectMetadata(result, { + completions: ["third"], matchedPrefixLength: 12, separatorMode: "spacePunctuation", closedSet: true, @@ -124,8 +124,8 @@ describeForEachCompletion( grammar, "first second th", ); - expect(result.completions).toContain("third"); expectMetadata(result, { + completions: ["third"], matchedPrefixLength: 12, separatorMode: "spacePunctuation", closedSet: true, @@ -151,8 +151,8 @@ describeForEachCompletion( grammar, "alpha bravo charlie", ); - expect(result.completions).toContain("delta"); expectMetadata(result, { + completions: ["delta"], matchedPrefixLength: 19, separatorMode: "spacePunctuation", closedSet: true, @@ -164,8 +164,8 @@ describeForEachCompletion( it("completes charlie after two parts matched", () => { const result = matchGrammarCompletion(grammar, "alpha bravo"); - expect(result.completions).toContain("charlie"); expectMetadata(result, { + completions: ["charlie"], matchedPrefixLength: 11, separatorMode: "spacePunctuation", closedSet: true, @@ -193,8 +193,8 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "alpha bravo"); // Short rule matches exactly, no completion from it. // Long rule matches alpha + bravo and offers "charlie". - expect(result.completions).toContain("charlie"); expectMetadata(result, { + completions: ["charlie"], matchedPrefixLength: 11, separatorMode: "spacePunctuation", closedSet: true, @@ -207,8 +207,8 @@ describeForEachCompletion( it("both rules offer completions at same depth for first part", () => { const result = matchGrammarCompletion(grammar, "alpha"); // Both rules need "bravo" next. - expect(result.completions).toContain("bravo"); expectMetadata(result, { + completions: ["bravo"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -230,9 +230,8 @@ describeForEachCompletion( it("offers both alternatives after shared prefix", () => { const result = matchGrammarCompletion(grammar, "prefix"); - expect(result.completions).toContain("suffix_x"); - expect(result.completions).toContain("suffix_y"); expectMetadata(result, { + completions: ["suffix_x", "suffix_y"], matchedPrefixLength: 6, separatorMode: "spacePunctuation", closedSet: true, @@ -274,9 +273,8 @@ describeForEachCompletion( it("offers both optional and skip alternatives after first part", () => { const result = matchGrammarCompletion(grammar, "begin"); // Should offer "middle" (optional) and "finish" (skipping optional) - expect(result.completions).toContain("middle"); - expect(result.completions).toContain("finish"); expectMetadata(result, { + completions: ["middle", "finish"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -288,8 +286,8 @@ describeForEachCompletion( it("offers finish after optional part matched", () => { const result = matchGrammarCompletion(grammar, "begin middle"); - expect(result.completions).toContain("finish"); expectMetadata(result, { + completions: ["finish"], matchedPrefixLength: 12, separatorMode: "spacePunctuation", closedSet: true, @@ -307,8 +305,8 @@ describeForEachCompletion( // offers "finish" at matchedPrefixLength=5 — but this // is filtered out because a longer match (12) exists. const result = matchGrammarCompletion(grammar, "begin middle"); - expect(result.completions).toContain("finish"); expectMetadata(result, { + completions: ["finish"], matchedPrefixLength: 12, separatorMode: "spacePunctuation", closedSet: true, @@ -327,8 +325,8 @@ describeForEachCompletion( it("completes 'percent' after number matched", () => { const result = matchGrammarCompletion(grammar, "set volume 50"); - expect(result.completions).toContain("percent"); expectMetadata(result, { + completions: ["percent"], matchedPrefixLength: 13, separatorMode: "optionalSpace", closedSet: true, @@ -343,8 +341,8 @@ describeForEachCompletion( grammar, "set volume 50 ", ); - expect(result.completions).toContain("percent"); expectMetadata(result, { + completions: ["percent"], matchedPrefixLength: 13, separatorMode: "optionalSpace", closedSet: true, @@ -385,10 +383,8 @@ describeForEachCompletion( it("offers all genre alternatives after 'play'", () => { const result = matchGrammarCompletion(grammar, "play"); - expect(result.completions).toContain("rock"); - expect(result.completions).toContain("pop"); - expect(result.completions).toContain("jazz"); expectMetadata(result, { + completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -402,10 +398,8 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play r"); // All genre alternatives are reported after the longest // complete match "play"; the caller filters by "r". - expect(result.completions).toContain("rock"); - expect(result.completions).toContain("pop"); - expect(result.completions).toContain("jazz"); expectMetadata(result, { + completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -418,10 +412,8 @@ describeForEachCompletion( it("all alternatives offered with 'p' trailing text", () => { const result = matchGrammarCompletion(grammar, "play p"); // All are reported; caller filters by "p". - expect(result.completions).toContain("pop"); - expect(result.completions).toContain("rock"); - expect(result.completions).toContain("jazz"); expectMetadata(result, { + completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -433,8 +425,8 @@ describeForEachCompletion( it("closedSet=true for first string part on empty input", () => { const result = matchGrammarCompletion(grammar, ""); - expect(result.completions).toContain("play"); expectMetadata(result, { + completions: ["play"], matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -470,22 +462,25 @@ describeForEachCompletion( it("offers wildcard property after 'play'", () => { const result = matchGrammarCompletion(grammar, "play"); - // Wildcard is next, should have property completion - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", + properties: [ + { + match: {}, + propertyNames: ["name"], + }, + ], }); }); it("offers 'by' terminator after wildcard text", () => { const result = matchGrammarCompletion(grammar, "play hello"); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 10, separatorMode: "spacePunctuation", closedSet: true, @@ -497,15 +492,18 @@ describeForEachCompletion( it("offers artist wildcard property after 'by'", () => { const result = matchGrammarCompletion(grammar, "play hello by"); - // After "by", the next part is the artist wildcard - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { matchedPrefixLength: 13, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", + properties: [ + { + match: { name: "hello" }, + propertyNames: ["artist"], + }, + ], }); }); }); @@ -520,8 +518,8 @@ describeForEachCompletion( it("completes 'deep' for empty input", () => { const result = matchGrammarCompletion(grammar, ""); - expect(result.completions).toContain("deep"); expectMetadata(result, { + completions: ["deep"], matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -533,8 +531,8 @@ describeForEachCompletion( it("completes 'done' after deeply nested match", () => { const result = matchGrammarCompletion(grammar, "deep"); - expect(result.completions).toContain("done"); expectMetadata(result, { + completions: ["done"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -552,8 +550,8 @@ describeForEachCompletion( it("offers 'hello' for empty input", () => { const result = matchGrammarCompletion(grammar, ""); - expect(result.completions).toContain("hello"); expectMetadata(result, { + completions: ["hello"], matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -566,9 +564,8 @@ describeForEachCompletion( it("offers repeat alternatives after 'hello'", () => { const result = matchGrammarCompletion(grammar, "hello"); // After "hello", the ()+ group requires at least one match - expect(result.completions).toContain("world"); - expect(result.completions).toContain("earth"); expectMetadata(result, { + completions: ["world", "earth"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -581,13 +578,8 @@ describeForEachCompletion( it("offers 'done' and repeat alternatives after first repeat match", () => { const result = matchGrammarCompletion(grammar, "hello world"); // After one repeat match, can repeat or proceed to "done" - expect(result.completions).toContain("done"); - // Also should offer repeat alternatives - expect( - result.completions.includes("world") || - result.completions.includes("earth"), - ).toBe(true); expectMetadata(result, { + completions: ["done", "world", "earth"], matchedPrefixLength: 11, separatorMode: "spacePunctuation", closedSet: true, @@ -602,8 +594,8 @@ describeForEachCompletion( grammar, "hello world earth", ); - expect(result.completions).toContain("done"); expectMetadata(result, { + completions: ["done", "world", "earth"], matchedPrefixLength: 17, separatorMode: "spacePunctuation", closedSet: true, @@ -639,8 +631,8 @@ describeForEachCompletion( // matchedPrefixLength should be at least 10 (from the longest match). expect(result.matchedPrefixLength).toBeGreaterThanOrEqual(10); // "gamma" must appear as completion from the longer match. - expect(result.completions).toContain("gamma"); expectMetadata(result, { + completions: ["gamma"], separatorMode: "spacePunctuation", closedSet: true, directionSensitive: true, @@ -675,8 +667,8 @@ describeForEachCompletion( it("completes after case-insensitive match", () => { const result = matchGrammarCompletion(grammar, "hello"); - expect(result.completions).toContain("World"); expectMetadata(result, { + completions: ["World"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -688,8 +680,8 @@ describeForEachCompletion( it("completes after uppercase input", () => { const result = matchGrammarCompletion(grammar, "HELLO"); - expect(result.completions).toContain("World"); expectMetadata(result, { + completions: ["World"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -701,8 +693,8 @@ describeForEachCompletion( it("partial prefix is case insensitive", () => { const result = matchGrammarCompletion(grammar, "hello WO"); - expect(result.completions).toContain("World"); expectMetadata(result, { + completions: ["World"], matchedPrefixLength: 5, separatorMode: "spacePunctuation", closedSet: true, @@ -726,10 +718,8 @@ describeForEachCompletion( it("only matching rule offers completions for 'play'", () => { const result = matchGrammarCompletion(grammar, "play"); - expect(result.completions).toContain("rock"); - expect(result.completions).not.toContain("now"); - expect(result.completions).not.toContain("stop"); expectMetadata(result, { + completions: ["rock"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -741,10 +731,8 @@ describeForEachCompletion( it("only matching rule offers completions for 'stop'", () => { const result = matchGrammarCompletion(grammar, "stop"); - expect(result.completions).toContain("now"); - expect(result.completions).not.toContain("rock"); - expect(result.completions).not.toContain("play"); expectMetadata(result, { + completions: ["now"], matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -759,8 +747,8 @@ describeForEachCompletion( // Nothing consumed; all first string parts from every rule // are offered at prefixLength 0. The caller filters by // the trailing text "dance". - expect(result.completions.sort()).toEqual(["play", "stop"]); expectMetadata(result, { + completions: ["play", "stop"], matchedPrefixLength: 0, separatorMode: "optionalSpace", closedSet: true, @@ -783,21 +771,25 @@ describeForEachCompletion( it("entity wildcard produces property completion", () => { const result = matchGrammarCompletion(grammar, "play"); - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", + properties: [ + { + match: {}, + propertyNames: ["song"], + }, + ], }); }); it("string terminator after entity text", () => { const result = matchGrammarCompletion(grammar, "play mysong"); - expect(result.completions).toContain("next"); expectMetadata(result, { + completions: ["next"], matchedPrefixLength: 11, separatorMode: "spacePunctuation", closedSet: true, @@ -822,21 +814,16 @@ describeForEachCompletion( const r1 = matchGrammarCompletion(grammar, "one"); const r2 = matchGrammarCompletion(grammar, "one "); const r3 = matchGrammarCompletion(grammar, "one "); - expect(r1.completions).toEqual(r2.completions); - expect(r2.completions).toEqual(r3.completions); - expect(r1.completions).toContain("two"); - expect(r1.separatorMode).toBe("spacePunctuation"); - expect(r2.separatorMode).toBe("spacePunctuation"); - expect(r3.separatorMode).toBe("spacePunctuation"); - expect(r1.closedSet).toBe(true); - expect(r2.closedSet).toBe(true); - expect(r3.closedSet).toBe(true); - expect(r1.afterWildcard).toBe("none"); - expect(r2.afterWildcard).toBe("none"); - expect(r3.afterWildcard).toBe("none"); - expect(r1.properties).toEqual([]); - expect(r2.properties).toEqual([]); - expect(r3.properties).toEqual([]); + const shared = { + completions: ["two"], + separatorMode: "spacePunctuation" as const, + closedSet: true, + afterWildcard: "none", + properties: [], + }; + expectMetadata(r1, shared); + expectMetadata(r2, shared); + expectMetadata(r3, shared); }); it("matchedPrefixLength does not include trailing whitespace", () => { @@ -844,13 +831,14 @@ describeForEachCompletion( const r2 = matchGrammarCompletion(grammar, "one "); const r3 = matchGrammarCompletion(grammar, "one "); // matchedPrefixLength stays at 3 regardless of trailing space. - expect(r1.matchedPrefixLength).toBe(3); - expect(r2.matchedPrefixLength).toBe(3); - expect(r3.matchedPrefixLength).toBe(3); // directionSensitive: true (P > 0) - expect(r1.directionSensitive).toBe(true); - expect(r2.directionSensitive).toBe(true); - expect(r3.directionSensitive).toBe(true); + const shared = { + matchedPrefixLength: 3, + directionSensitive: true, + }; + expectMetadata(r1, shared); + expectMetadata(r2, shared); + expectMetadata(r3, shared); }); }); @@ -868,15 +856,19 @@ describeForEachCompletion( ].join("\n"); const grammar = loadGrammarRules("test.grammar", g); const result = matchGrammarCompletion(grammar, "play"); - expect(result.matchedPrefixLength).toBe(4); - expect(result.completions).toContain("shuffle"); - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { + completions: ["shuffle"], + matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", + properties: [ + { + match: { action: "search" }, + propertyNames: ["song"], + }, + ], }); }); @@ -888,15 +880,19 @@ describeForEachCompletion( ].join("\n"); const grammar = loadGrammarRules("test.grammar", g); const result = matchGrammarCompletion(grammar, "play"); - expect(result.matchedPrefixLength).toBe(4); - expect(result.completions).toContain("shuffle"); - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { + completions: ["shuffle"], + matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", + properties: [ + { + match: { action: "search" }, + propertyNames: ["song"], + }, + ], }); }); }); @@ -921,8 +917,8 @@ describeForEachCompletion( // Rule 1 matches alpha (5 chars) and offers entity // (closedSet=false) — but this is at a shorter prefix // length so it's discarded. - expect(result.completions).toContain("finish"); expectMetadata(result, { + completions: ["finish"], matchedPrefixLength: 11, separatorMode: "spacePunctuation", closedSet: true, @@ -946,8 +942,8 @@ describeForEachCompletion( grammar, "play hello", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 10, separatorMode: "spacePunctuation", closedSet: true, @@ -962,8 +958,8 @@ describeForEachCompletion( grammar, "play my favorite song", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 21, separatorMode: "spacePunctuation", closedSet: true, @@ -1074,8 +1070,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1090,14 +1086,18 @@ describeForEachCompletion( grammar, "play something by", ); - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { matchedPrefixLength: 17, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", + properties: [ + { + match: { name: "something" }, + propertyNames: ["artist"], + }, + ], }); }); @@ -1111,8 +1111,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1159,8 +1159,8 @@ describeForEachCompletion( it("forward: offers 'by' with afterWildcard=\"all\"", () => { const result = matchGrammarCompletion(grammar, "play Never b"); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 10, separatorMode: "spacePunctuation", closedSet: true, @@ -1177,8 +1177,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 10, separatorMode: "spacePunctuation", closedSet: true, @@ -1193,8 +1193,8 @@ describeForEachCompletion( grammar, "play my favorite song b", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 21, separatorMode: "spacePunctuation", closedSet: true, @@ -1211,8 +1211,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 21, separatorMode: "spacePunctuation", closedSet: true, @@ -1236,8 +1236,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("playedby"); expectMetadata(result, { + completions: ["playedby"], matchedPrefixLength: 9, separatorMode: "none", closedSet: true, @@ -1254,8 +1254,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("playedby"); expectMetadata(result, { + completions: ["playedby"], matchedPrefixLength: 9, separatorMode: "none", closedSet: true, @@ -1272,8 +1272,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("playedby"); expectMetadata(result, { + completions: ["playedby"], matchedPrefixLength: 13, separatorMode: "none", closedSet: true, diff --git a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts index 2023aefda..fe46a1773 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts @@ -181,8 +181,8 @@ describeForEachCompletion( undefined, "forward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 17, separatorMode: "spacePunctuation", closedSet: true, @@ -202,14 +202,18 @@ describeForEachCompletion( // Trailing space is inside the wildcard content, not // after a committed keyword boundary — backward still // backs up to the wildcard start. - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); expectMetadata(result, { matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", + properties: [ + { + match: {}, + propertyNames: ["name"], + }, + ], }); }); @@ -220,8 +224,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("played"); expectMetadata(result, { + completions: ["played"], matchedPrefixLength: 10, separatorMode: "spacePunctuation", closedSet: true, @@ -265,8 +269,8 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 17, separatorMode: "spacePunctuation", closedSet: true, diff --git a/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts index cc4fc8cac..0657b185b 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts @@ -29,22 +29,46 @@ describeForEachCompletion( // After matching "play", the next part is $(trackName:) // which ultimately resolves to a wildcard. The completion should // include a property for that wildcard, not just "by". - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); + expectMetadata(result, { + properties: [ + { + match: { + actionName: "playTrack", + parameters: { + trackName: undefined, + artists: [undefined], + }, + }, + propertyNames: ["parameters.trackName"], + }, + ], + }); }); it('should return completionProperty for wildcard after "play "', () => { const result = matchGrammarCompletion(grammar, "play "); // Same as above but with trailing space - expect(result.properties).toBeDefined(); - expect(result.properties!.length).toBeGreaterThan(0); + expectMetadata(result, { + properties: [ + { + match: { + actionName: "playTrack", + parameters: { + trackName: undefined, + artists: [undefined], + }, + }, + propertyNames: ["parameters.trackName"], + }, + ], + }); }); it('should return "by" as completion after wildcard text', () => { const result = matchGrammarCompletion(grammar, "play some song"); // After the wildcard has captured text, "by" should appear as a // completion for the next string part. - expect(result.completions).toContain("by"); + expectMetadata(result, { completions: ["by"] }); }); it('forward: partial keyword "b" anchors at partial position, not end-of-input', () => { @@ -55,8 +79,8 @@ describeForEachCompletion( // against the typed "b", rather than position 17 (end-of-input) // with separatorMode "spacePunctuation" which hides the menu. const result = matchGrammarCompletion(grammar, "play This Train b"); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 15, separatorMode: "spacePunctuation", afterWildcard: "all", @@ -65,8 +89,8 @@ describeForEachCompletion( it('forward: partial keyword "b" works with single-word wildcard', () => { const result = matchGrammarCompletion(grammar, "play Nevermind b"); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 14, separatorMode: "spacePunctuation", afterWildcard: "all", @@ -77,7 +101,7 @@ describeForEachCompletion( // "play some song" — no trailing partial keyword. // "by" is offered at end-of-input (position 14). const result = matchGrammarCompletion(grammar, "play some song"); - expect(result.completions).toContain("by"); + expectMetadata(result, { completions: ["by"] }); }); }, ); diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index 04f69e9fb..1e0db7943 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -222,7 +222,6 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play "); expectMetadata(result, { completions: ["music", "video"], - sortCompletions: true, matchedPrefixLength: 4, separatorMode: "spacePunctuation", closedSet: true, @@ -2826,14 +2825,7 @@ describeForEachCompletion( matchedPrefixLength: 4, directionSensitive: true, }); - // Completions and position match; directionSensitive - // may differ (backward from exact match is always - // direction-agnostic, forward at truncated prefix - // is direction-sensitive for partial matches). - expect(backward.completions).toEqual(forward.completions); - expect(backward.matchedPrefixLength).toBe( - forward.matchedPrefixLength, - ); + expect(backward).toEqual(forward); }); it("backward on 'play' equals forward on ''", () => { @@ -2885,12 +2877,7 @@ describeForEachCompletion( matchedPrefixLength: 4, directionSensitive: true, }); - // Completions and position match; directionSensitive - // may differ. - expect(backward.completions).toEqual(forward.completions); - expect(backward.matchedPrefixLength).toBe( - forward.matchedPrefixLength, - ); + expect(backward).toEqual(forward); }); }); @@ -3372,8 +3359,8 @@ describeForEachCompletion( // Rule B (literal): "b" partial-matches "beautiful" // at mpl=4, which is shorter — discarded. // Only wildcard candidates survive → afterWildcard="all". - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 6, closedSet: true, directionSensitive: true, @@ -3394,9 +3381,8 @@ describeForEachCompletion( // Rule A: wildcard-at-EOI, Phase 2 offers "by" // (after-wildcard completion). // Both at mpl=14. Mixed → afterWildcard="some". - expect(result.completions).toContain("music"); - expect(result.completions).toContain("by"); expectMetadata(result, { + completions: ["music", "by"], matchedPrefixLength: 14, closedSet: true, directionSensitive: true, @@ -3417,9 +3403,8 @@ describeForEachCompletion( // Rule B: "hello" doesn't match "beautiful" — no // contribution at this prefix length. // Only wildcard candidates → afterWildcard="all". - expect(result.completions).toContain("by"); - expect(result.completions).not.toContain("beautiful"); expectMetadata(result, { + completions: ["by"], matchedPrefixLength: 10, closedSet: true, directionSensitive: true, @@ -3708,7 +3693,6 @@ describeForEachCompletion( // should appear: "by", "from", "track", "song". expectMetadata(result, { completions: ["by", "from", "song", "track"], - sortCompletions: true, matchedPrefixLength: 18, afterWildcard: "all", directionSensitive: true, @@ -3724,7 +3708,6 @@ describeForEachCompletion( ); expectMetadata(result, { completions: ["by", "from", "song", "track"], - sortCompletions: true, matchedPrefixLength: 18, afterWildcard: "all", directionSensitive: true, @@ -3743,7 +3726,6 @@ describeForEachCompletion( ); expectMetadata(result, { completions: ["song", "the", "track"], - sortCompletions: true, matchedPrefixLength: 4, afterWildcard: "none", directionSensitive: true, @@ -3779,7 +3761,6 @@ describeForEachCompletion( ); expectMetadata(result, { completions: ["by", "cut", "from", "one", "song", "track"], - sortCompletions: true, matchedPrefixLength: 18, directionSensitive: true, afterWildcard: "all", @@ -3798,7 +3779,6 @@ describeForEachCompletion( // and "cut" from PlayTrackNumberCommand's ()?. expectMetadata(result, { completions: ["by", "cut", "from", "one", "song", "track"], - sortCompletions: true, matchedPrefixLength: 18, directionSensitive: true, afterWildcard: "all", @@ -3857,8 +3837,10 @@ describeForEachCompletion( undefined, "forward", ); - expect(result.completions).toContain("by"); - expect(result.matchedPrefixLength).toBe(15); + expectMetadata(result, { + completions: ["by", "one", "cut", "track", "song", "from"], + matchedPrefixLength: 15, + }); }); }); }, diff --git a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts index f83f78b41..8e1876961 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts @@ -408,12 +408,19 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "hello"); // Both alternatives match "hello" and offer "world". // Required mode → spacePunctuation; optional mode → - // optional. No "none" mode present, so no conflict. - // Normal merge: spacePunctuation is strongest. + // optionalSpacePunctuation. Per-group: two separate groups. expectMetadata(result, { - completions: ["world"], + groups: [ + { + completions: ["world"], + separatorMode: "spacePunctuation", + }, + { + completions: ["world"], + separatorMode: "optionalSpacePunctuation", + }, + ], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -423,12 +430,20 @@ describeForEachCompletion( it("trailing separator: normal merge, no P advancement", () => { const result = matchGrammarCompletion(grammar, "hello "); - // No conflict → no filtering, no P advancement. - // Normal merge at P=5 with trailing separator. + // Trailing separator: both alternatives still offer "world". + // Per-group: two separate groups with their respective modes. expectMetadata(result, { - completions: ["world"], + groups: [ + { + completions: ["world"], + separatorMode: "spacePunctuation", + }, + { + completions: ["world"], + separatorMode: "optionalSpacePunctuation", + }, + ], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -457,13 +472,18 @@ describeForEachCompletion( it("no trailing separator: keeps none-mode completions", () => { const result = matchGrammarCompletion(grammar, "ab"); - // Conflict: NoneRule → "none", AutoRule → "spacePunctuation" - // No trailing sep → drop spacePunctuation, keep "none" + // Per-group: NoneRule → "none" group, AutoRule → "spacePunctuation" group. + // No conflict filtering — both kept. expectMetadata(result, { - completions: ["cd"], + groups: [ + { completions: ["cd"], separatorMode: "none" }, + { + completions: ["cd"], + separatorMode: "spacePunctuation", + }, + ], matchedPrefixLength: 2, - separatorMode: "none", - closedSet: false, + closedSet: true, directionSensitive: true, afterWildcard: "none", properties: [], @@ -472,14 +492,17 @@ describeForEachCompletion( it("trailing separator: keeps requiring completions, P advanced", () => { const result = matchGrammarCompletion(grammar, "ab "); - // Trailing space → drop "none", keep "spacePunctuation" - // P advances from 2 to 3 (past the space). - // separatorMode is "optionalSpace" since sep is consumed. + // Per-group: both kept; no filtering. expectMetadata(result, { - completions: ["cd"], - matchedPrefixLength: 3, - separatorMode: "optionalSpace", - closedSet: false, + groups: [ + { completions: ["cd"], separatorMode: "none" }, + { + completions: ["cd"], + separatorMode: "spacePunctuation", + }, + ], + matchedPrefixLength: 2, + closedSet: true, directionSensitive: true, afterWildcard: "none", properties: [], @@ -505,15 +528,21 @@ describeForEachCompletion( it("no trailing separator: keeps none + optional", () => { const result = matchGrammarCompletion(grammar, "ab"); - // Conflict: NoneRule → "none", OptRule → "optionalSpacePunctuation", - // ReqRule → "spacePunctuation" - // No trailing sep → drop spacePunctuation - // Keep "none" + "optionalSpacePunctuation" → merge to "optionalSpacePunctuation" + // Per-group: three separate groups, one per spacing mode. expectMetadata(result, { - completions: ["cd"], + groups: [ + { completions: ["cd"], separatorMode: "none" }, + { + completions: ["cd"], + separatorMode: "optionalSpacePunctuation", + }, + { + completions: ["cd"], + separatorMode: "spacePunctuation", + }, + ], matchedPrefixLength: 2, - separatorMode: "optionalSpacePunctuation", - closedSet: false, + closedSet: true, directionSensitive: true, afterWildcard: "none", properties: [], @@ -522,15 +551,21 @@ describeForEachCompletion( it("trailing separator: keeps optional + required, drops none, P advanced", () => { const result = matchGrammarCompletion(grammar, "ab "); - // Trailing space → drop "none", keep "spacePunctuation" + "optionalSpacePunctuation" - // P advances to 3 (past space). - // Both spacePunctuation and optionalSpacePunctuation merge to - // "optionalSpace" (sep already consumed into P). + // Per-group: three groups kept; no filtering. expectMetadata(result, { - completions: ["cd"], - matchedPrefixLength: 3, - separatorMode: "optionalSpace", - closedSet: false, + groups: [ + { completions: ["cd"], separatorMode: "none" }, + { + completions: ["cd"], + separatorMode: "optionalSpacePunctuation", + }, + { + completions: ["cd"], + separatorMode: "spacePunctuation", + }, + ], + matchedPrefixLength: 2, + closedSet: true, directionSensitive: true, afterWildcard: "none", properties: [], @@ -1057,9 +1092,10 @@ describeForEachCompletion( // NoneRule: Cat 3b → P=2, offers "cd" // WildcardRule: wildcard absorbs "ab", EOI deferred → // Phase 2 instantiates "done" at P=2 - expect(result.matchedPrefixLength).toBe(2); - expect(result.completions).toContain("cd"); - expect(result.completions).toContain("done"); + expectMetadata(result, { + completions: ["cd", "done"], + matchedPrefixLength: 2, + }); }); it("backward 'abdo': Phase 2 advances P, shadow flushes 'cd'", () => { @@ -1086,9 +1122,10 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.matchedPrefixLength).toBe(2); - expect(result.completions).toContain("cd"); - expect(result.completions).toContain("done"); + expectMetadata(result, { + completions: ["done", "cd"], + matchedPrefixLength: 2, + }); }); }); @@ -1111,9 +1148,10 @@ describeForEachCompletion( it("forward 'ab': both rules contribute at P=2", () => { const result = matchGrammarCompletion(grammar, "ab"); - expect(result.matchedPrefixLength).toBe(2); - expect(result.completions).toContain("1cd"); - expect(result.completions).toContain("1done"); + expectMetadata(result, { + completions: ["1cd", "1done"], + matchedPrefixLength: 2, + }); }); it("backward 'ab1do': Phase 2 advances P, shadow flushes '1cd'", () => { @@ -1132,9 +1170,10 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.matchedPrefixLength).toBe(2); - expect(result.completions).toContain("1cd"); - expect(result.completions).toContain("1done"); + expectMetadata(result, { + completions: ["1done", "1cd"], + matchedPrefixLength: 2, + }); }); }); @@ -1161,9 +1200,10 @@ describeForEachCompletion( it("forward 'hello ': both rules at P=5", () => { const result = matchGrammarCompletion(grammar, "hello "); - expect(result.matchedPrefixLength).toBe(5); - expect(result.completions).toContain("world"); - expect(result.completions).toContain("there"); + expectMetadata(result, { + completions: ["world", "there"], + matchedPrefixLength: 5, + }); }); it("backward 'hello ': Cat 2 takes forward path, both completions present", () => { @@ -1178,9 +1218,10 @@ describeForEachCompletion( undefined, "backward", ); - expect(result.matchedPrefixLength).toBe(5); - expect(result.completions).toContain("world"); - expect(result.completions).toContain("there"); + expectMetadata(result, { + completions: ["world", "there"], + matchedPrefixLength: 5, + }); }); }); @@ -1230,13 +1271,12 @@ describeForEachCompletion( // (invariant #3: result at P is a function of // input[0..P] alone). const result = matchGrammarCompletion(grammar, "ab foo c"); - expect(result.completions).toContain("cd"); - expect(result.matchedPrefixLength).toBe(6); - // closedSet=true: no drops at the displaced anchor. - expect(result.closedSet).toBe(true); - // afterWildcard="all": all completions are after a - // wildcard (partial keyword inside wildcard). - expect(result.afterWildcard).toBe("all"); + expectMetadata(result, { + completions: ["cd"], + matchedPrefixLength: 6, + closedSet: true, + afterWildcard: "all", + }); }); }); diff --git a/ts/packages/actionGrammar/test/nfaDfaParity.spec.ts b/ts/packages/actionGrammar/test/nfaDfaParity.spec.ts index 6b7e5c1aa..0e690fe48 100644 --- a/ts/packages/actionGrammar/test/nfaDfaParity.spec.ts +++ b/ts/packages/actionGrammar/test/nfaDfaParity.spec.ts @@ -118,7 +118,9 @@ function assertCompletionParity( const dfaComp = getDFACompletions(dfa, prefixTokens); // Literal token completions (sort for deterministic comparison) - const nfaLiterals = [...(nfaComp.completions ?? [])].sort(); + const nfaLiterals = [ + ...nfaComp.groups.flatMap((g) => g.completions), + ].sort(); const dfaLiterals = [...(dfaComp.completions ?? [])].sort(); expect(dfaLiterals).toEqual(nfaLiterals); @@ -1822,7 +1824,9 @@ describe("PhraseSet Completion Parity", () => { // Verify DFA produces a superset. const nfaComp = computeNFACompletions(nfa, []); const dfaComp = getDFACompletions(dfa, []); - const nfaLiterals = [...(nfaComp.completions ?? [])].sort(); + const nfaLiterals = [ + ...nfaComp.groups.flatMap((g) => g.completions), + ].sort(); const dfaLiterals = [...(dfaComp.completions ?? [])].sort(); expect(dfaLiterals.length).toBeGreaterThanOrEqual(nfaLiterals.length); expect(dfaLiterals).toContain("please"); diff --git a/ts/packages/actionGrammar/test/testUtils.ts b/ts/packages/actionGrammar/test/testUtils.ts index da997dc8c..735d7b8fb 100644 --- a/ts/packages/actionGrammar/test/testUtils.ts +++ b/ts/packages/actionGrammar/test/testUtils.ts @@ -135,7 +135,12 @@ function testCompletionDFA( const tokens = tokenizeRequest(prefix); const result = getDFACompletions(dfa, tokens); return { - completions: result.completions ?? [], + groups: [ + { + completions: result.completions ?? [], + separatorMode: "space", + }, + ], properties: result.properties?.map((p) => ({ match: { actionName: p.actionName }, propertyNames: [p.propertyPath], @@ -210,13 +215,25 @@ function completionResultsEqual( } /** - * Return a copy of a completion result with completions sorted. + * Sort completions within each group and sort groups by separatorMode. + * Both completion order and group order are not significant. + */ +function normalizeGroups< + T extends { completions: string[]; separatorMode: string }, +>(groups: T[]): T[] { + return groups + .map((g) => ({ ...g, completions: [...g.completions].sort() })) + .sort((a, b) => a.separatorMode.localeCompare(b.separatorMode)); +} + +/** + * Return a copy of a completion result with normalized groups. * Completion order is not significant — normalize before comparing. */ function normalizeCompletionResult(r: GrammarCompletionResult) { return { ...r, - completions: [...r.completions].sort(), + groups: normalizeGroups(r.groups), }; } @@ -250,7 +267,7 @@ function assertSingleResultInvariants( // 2. closedSet ↔ properties consistency const hasProperties = (result.properties?.length ?? 0) > 0; - const hasCompletions = result.completions.length > 0; + const hasCompletions = result.groups.some((g) => g.completions.length > 0); if (hasProperties && result.closedSet !== false) { throw new Error( `Invariant: properties present but closedSet=${result.closedSet} (should be false) ${ctx}`, @@ -538,11 +555,19 @@ function withInvariantChecks(baseFn: TestCompletionFn): TestCompletionFn { /** * Assert completion metadata fields in a canonical order. * Only the fields present in `expected` are checked. + * + * Supports two formats: + * - Flat (backward-compatible): { completions, separatorMode, ... } + * When the result has exactly one group, `completions` and + * `separatorMode` are checked against that single group. + * - Grouped: { groups: [{ completions, separatorMode }, ...], ... } + * Each group is checked individually. */ export function expectMetadata( result: GrammarCompletionResult, expected: { completions?: string[]; + groups?: { completions: string[]; separatorMode: string }[]; matchedPrefixLength?: number; separatorMode?: | "space" @@ -555,25 +580,58 @@ export function expectMetadata( directionSensitive?: boolean; afterWildcard?: string; properties?: unknown[]; - sortCompletions?: boolean; }, ): void { - if ("completions" in expected) { - const actual = expected.sortCompletions - ? [...result.completions].sort() - : result.completions; - expect(actual).toEqual(expected.completions); + if ("groups" in expected && "completions" in expected) { + throw new Error( + "expectMetadata: 'groups' and 'completions' are mutually exclusive", + ); } - if ("matchedPrefixLength" in expected) { - expect(result.matchedPrefixLength).toBe(expected.matchedPrefixLength); + + // Convert flat format to grouped, then use a common check path. + let expectedGroups: + | { completions: string[]; separatorMode?: string }[] + | undefined; + if ("groups" in expected && expected.groups !== undefined) { + expectedGroups = expected.groups; + } else if ("completions" in expected) { + expectedGroups = + expected.completions.length === 0 + ? [] + : [ + { + completions: expected.completions, + ...("separatorMode" in expected && + expected.separatorMode !== undefined + ? { separatorMode: expected.separatorMode } + : {}), + }, + ]; } - if ("separatorMode" in expected) { - if (expected.separatorMode === undefined) { - expect(result.separatorMode).toBeUndefined(); + + // Common check path for groups. + if (expectedGroups !== undefined) { + const checkSeparatorMode = expectedGroups.some( + (g) => g.separatorMode !== undefined, + ); + const normalizedActual = normalizeGroups(result.groups); + const normalizedExpected = normalizeGroups( + expectedGroups.map((g) => ({ + ...g, + separatorMode: g.separatorMode ?? "space", + })), + ); + if (checkSeparatorMode) { + expect(normalizedActual).toEqual(normalizedExpected); } else { - expect(result.separatorMode).toBe(expected.separatorMode); + expect(normalizedActual.map((g) => g.completions)).toEqual( + normalizedExpected.map((g) => g.completions), + ); } } + if ("matchedPrefixLength" in expected) { + expect(result.matchedPrefixLength).toBe(expected.matchedPrefixLength); + } if ("closedSet" in expected) { expect(result.closedSet).toBe(expected.closedSet); } diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index eb9cf52a2..9c48af20e 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -116,6 +116,7 @@ export type AfterWildcard = "none" | "some" | "all"; export type CompletionGroup = { name: string; // The group name for the completion completions: string[]; // The list of completions in the group + separatorMode?: SeparatorMode | undefined; // What separator is required before this group's completions. Default is "space". needQuotes?: boolean; // If true, the completion should be quoted if it has spaces. emojiChar?: string | undefined; // Optional icon for the completion category sorted?: boolean; // If true, the completions are already sorted. Default is false, and the completions sorted alphabetically. @@ -131,10 +132,6 @@ export type CompletionGroups = { // completions at this offset; clients need not split on spaces // (which fails for CJK and other non-space-delimited scripts). matchedPrefixLength?: number | undefined; - // What kind of separator is required between the matched prefix and - // the completion text. When omitted, defaults to "space" (whitespace - // required before completions are shown). See SeparatorMode. - separatorMode?: SeparatorMode | undefined; // True when the completions form a closed set — if the user types // something not in the list, no further completions can exist // beyond it. When true and the user types something that doesn't diff --git a/ts/packages/agentSdk/src/helpers/commandHelpers.ts b/ts/packages/agentSdk/src/helpers/commandHelpers.ts index 0b2eb69bf..df24ee0d2 100644 --- a/ts/packages/agentSdk/src/helpers/commandHelpers.ts +++ b/ts/packages/agentSdk/src/helpers/commandHelpers.ts @@ -9,29 +9,8 @@ import { CommandDescriptorTable, CompletionDirection, CompletionGroups, - SeparatorMode, } from "../command.js"; -// Merge two SeparatorMode values — the mode requiring the strongest -// separator wins (i.e. the mode that demands the most from the user). -// Priority: "space" > "spacePunctuation" > "optionalSpacePunctuation" -// > "optionalSpace" > "none" > undefined. -// Architecture: docs/architecture/completion.md — §3 Agent SDK -export function mergeSeparatorMode( - a: SeparatorMode | undefined, - b: SeparatorMode | undefined, -): SeparatorMode | undefined { - if (a === undefined) return b; - if (b === undefined) return a; - const order: Record = { - space: 4, - spacePunctuation: 3, - optionalSpacePunctuation: 2, - optionalSpace: 1, - none: 0, - }; - return order[a] >= order[b] ? a : b; -} import { ParameterDefinitions, ParsedCommandParams, diff --git a/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts b/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts index 228dc434f..14ba10ba7 100644 --- a/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts +++ b/ts/packages/agents/browser/src/extension/serviceWorker/serviceWorkerRpcHandlers.ts @@ -256,11 +256,12 @@ export function createAllHandlers(): AllServiceWorkerInvokeFunctions { } const startIndex = result.startIndex; const prefix = params.input.substring(0, startIndex); - const separator = - result.separatorMode === "space" || - result.separatorMode === "spacePunctuation" - ? " " - : ""; + const needsSpace = result.completions.some( + (g) => + g.separatorMode === "space" || + g.separatorMode === "spacePunctuation", + ); + const separator = needsSpace ? " " : ""; return { completions, startIndex, diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index b36b0ff56..3c015e187 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -7,8 +7,6 @@ import { matchGrammar, matchGrammarCompletion, GrammarCompletionResult, - isRequiringSepMode, - hasTrailingSeparator, NFA, compileGrammarToNFA, matchGrammarWithNFA, @@ -23,10 +21,9 @@ import { const debug = registerDebug("typeagent:cache:grammarStore"); import { CompletionDirection, - SeparatorMode, + CompletionGroup, AfterWildcard, } from "@typeagent/agent-sdk"; -import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; import { CompletionProperty, CompletionResult, @@ -288,10 +285,9 @@ export class GrammarStoreImpl implements GrammarStore { if (namespaceKeys?.length === 0) { return undefined; } - const completions: string[] = []; + const groups: CompletionGroup[] = []; const properties: CompletionProperty[] = []; let matchedPrefixLength = 0; - let separatorMode: SeparatorMode | undefined; let closedSet: boolean | undefined; let directionSensitive: boolean = false; let afterWildcard: AfterWildcard | undefined; @@ -319,7 +315,10 @@ export class GrammarStoreImpl implements GrammarStore { dfaCompResult.completions && dfaCompResult.completions.length > 0 ) { - completions.push(...dfaCompResult.completions); + groups.push({ + name: "Request Completions", + completions: dfaCompResult.completions, + }); } if ( dfaCompResult.properties && @@ -347,8 +346,14 @@ export class GrammarStoreImpl implements GrammarStore { .filter((t) => t.length > 0); const nfaResult = computeNFACompletions(entry.nfa, tokens); - if (nfaResult.completions.length > 0) { - completions.push(...nfaResult.completions); + for (const g of nfaResult.groups) { + if (g.completions.length > 0) { + groups.push({ + name: "Request Completions", + completions: g.completions, + separatorMode: g.separatorMode, + }); + } } if ( nfaResult.properties !== undefined && @@ -393,9 +398,8 @@ export class GrammarStoreImpl implements GrammarStore { if (!adopt) continue; matchedPrefixLength = partialPrefixLength; - completions.length = 0; + groups.length = 0; properties.length = 0; - separatorMode = undefined; closedSet = undefined; directionSensitive = false; afterWildcard = undefined; @@ -408,70 +412,27 @@ export class GrammarStoreImpl implements GrammarStore { } } - // Post-loop merge of grammar-based results with cross-grammar - // separator mode conflict detection. - // - // Each individual grammar's result is already internally - // consistent (within-grammar conflict filtering in - // grammarCompletion.ts Phase 2 — see filterSepConflicts). - // Here we detect when different grammars produce incompatible - // separator modes and filter by trailing separator state, - // mirroring the same detect/filter/advance/force pattern. + // Post-loop merge of grammar-based results — collect per-group + // completions from all grammars. No cross-grammar conflict + // filtering: each group carries its own separatorMode and the + // shell shows/hides groups based on separator state. if (grammarPartials.length > 0) { - let hasRequiring = false; - let hasNoneMode = false; - for (const { partial } of grammarPartials) { - if (partial.separatorMode !== undefined) { - if (isRequiringSepMode(partial.separatorMode)) { - hasRequiring = true; - } - if (partial.separatorMode === "none") { - hasNoneMode = true; - } - } - } - - const hasCrossGrammarConflict = hasRequiring && hasNoneMode; - const hasTrailingSep = - hasCrossGrammarConflict && - hasTrailingSeparator(input, matchedPrefixLength); - - let effectivePartials = grammarPartials; - if (hasCrossGrammarConflict) { - effectivePartials = grammarPartials.filter(({ partial }) => { - if (partial.separatorMode === undefined) return true; - if (hasTrailingSep) { - // Trailing separator: drop "none" mode grammars. - return partial.separatorMode !== "none"; - } else { - // No trailing separator: drop requiring modes. - return !isRequiringSepMode(partial.separatorMode); - } - }); - } - - // Merge surviving grammar partials. - for (const { partial, schemaName } of effectivePartials) { - completions.push(...partial.completions); - if (partial.separatorMode !== undefined) { - separatorMode = mergeSeparatorMode( - separatorMode, - partial.separatorMode, - ); - } - // AND-merge: closed set only when all grammar - // results at this prefix length are closed sets. + for (const { partial, schemaName } of grammarPartials) { + groups.push( + ...partial.groups.map((g) => ({ + name: "Request Completions", + completions: g.completions, + separatorMode: g.separatorMode, + })), + ); if (partial.closedSet !== undefined) { closedSet = closedSet === undefined ? partial.closedSet : closedSet && partial.closedSet; } - // True if any grammar result at this prefix - // length is direction-sensitive. directionSensitive = directionSensitive || partial.directionSensitive; - // Tri-state merge for afterWildcard. afterWildcard = mergeAfterWildcard( afterWildcard, partial.afterWildcard, @@ -495,36 +456,12 @@ export class GrammarStoreImpl implements GrammarStore { } } } - - // When grammars were dropped due to separator conflict, - // advance P past trailing separator and adjust metadata - // so the shell re-fetches when separator state changes. - if (hasCrossGrammarConflict) { - closedSet = false; - if (hasTrailingSep) { - // Advance past exactly one trailing separator - // (not all consecutive separators). Each - // backspace in a multi-separator run produces - // a distinct anchor for re-fetch. Remaining - // separators are stripped by the shell's - // "optionalSpace" mode handling, keeping the menu - // visible with a clean trie prefix. - matchedPrefixLength += 1; - separatorMode = "optionalSpace"; - } - // Force afterWildcard "all" → "some" so shell - // doesn't use "slide" noMatchPolicy. - if (afterWildcard === "all") { - afterWildcard = "some"; - } - } } return { - completions, + groups, properties, matchedPrefixLength, - separatorMode, closedSet, directionSensitive, afterWildcard, diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 4141d316b..1314be9ff 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -3,10 +3,10 @@ import { CompletionDirection, + CompletionGroup, SeparatorMode, AfterWildcard, } from "@typeagent/agent-sdk"; -import { mergeSeparatorMode } from "@typeagent/agent-sdk/helpers/command"; import { ExecutableAction, HistoryContext, @@ -83,13 +83,10 @@ export type CompletionProperty = { }; export type CompletionResult = { - completions: string[]; + groups: CompletionGroup[]; properties?: CompletionProperty[] | undefined; // Characters consumed by the grammar before the completion point. matchedPrefixLength?: number | undefined; - // What kind of separator is required between the already-typed prefix - // and the completion text. See SeparatorMode in @typeagent/agent-sdk. - separatorMode?: SeparatorMode | undefined; // True when the completions form a closed set — if the user types // something not in the list, no further completions can exist // beyond it. False or undefined means the parser can continue @@ -214,24 +211,20 @@ export function mergeCompletionResults( ? second : first; } - // Same prefix length — merge completions from both sources. + // Same prefix length — merge groups from both sources. const matchedPrefixLength = first.matchedPrefixLength !== undefined || second.matchedPrefixLength !== undefined ? firstLen : undefined; return { - completions: [...first.completions, ...second.completions], + groups: [...first.groups, ...second.groups], properties: first.properties ? second.properties ? [...first.properties, ...second.properties] : first.properties : second.properties, matchedPrefixLength, - separatorMode: mergeSeparatorMode( - first.separatorMode, - second.separatorMode, - ), // Closed set only when both sources are closed sets. closedSet: first.closedSet !== undefined || second.closedSet !== undefined @@ -515,8 +508,8 @@ export class ConstructionCache { // discarded — mirroring the grammar matcher's approach. let maxPrefixLength = 0; const completionProperty: CompletionProperty[] = []; - const requestText: string[] = []; - let separatorMode: SeparatorMode | undefined; + // Per-mode string completion buckets. + const modeCompletions = new Map(); // Whether the accumulated completions form a closed set. // Starts true; set to false when property/wildcard completions // are added (entity values are external). Reset to true when @@ -529,12 +522,20 @@ export class ConstructionCache { const rejectReferences = options?.rejectReferences ?? true; const langTools = getLanguageTools("en"); + function addCompletion(text: string, mode: SeparatorMode): void { + let bucket = modeCompletions.get(mode); + if (bucket === undefined) { + bucket = []; + modeCompletions.set(mode, bucket); + } + bucket.push(text); + } + function updateMaxPrefixLength(prefixLength: number): void { if (prefixLength > maxPrefixLength) { maxPrefixLength = prefixLength; - requestText.length = 0; + modeCompletions.clear(); completionProperty.length = 0; - separatorMode = undefined; closedSet = true; hasMatchedPart = false; } @@ -610,7 +611,7 @@ export class ConstructionCache { ) { continue; } - requestText.push(completionText); + let mode: SeparatorMode = "optionalSpace"; if ( candidatePrefixLength > 0 && completionText.length > 0 @@ -619,11 +620,11 @@ export class ConstructionCache { input[candidatePrefixLength - 1], completionText[0], ); - separatorMode = mergeSeparatorMode( - separatorMode, - needsSep ? "spacePunctuation" : "optionalSpace", - ); + mode = needsSep + ? "spacePunctuation" + : "optionalSpace"; } + addCompletion(completionText, mode); } } } @@ -657,16 +658,6 @@ export class ConstructionCache { actions: result.match.actions, names: queryPropertyNames, }); - if (candidatePrefixLength > 0) { - const needsSep = needsSeparatorInAutoMode( - input[candidatePrefixLength - 1], - "a", - ); - separatorMode = mergeSeparatorMode( - separatorMode, - needsSep ? "spacePunctuation" : "optionalSpace", - ); - } closedSet = false; } } @@ -675,18 +666,11 @@ export class ConstructionCache { // Advance past trailing separators so that the reported prefix // length includes any trailing whitespace the user typed. - // When advancing, demote separatorMode to "optionalSpace" — the - // trailing space is already consumed. - // - // Skip advancement when backward backed up: the backed-up - // position is where backward wants completions to anchor. - // Advancing past trailing separator chars would defeat the - // backup (e.g. separator-only keywords like "..."). + // No longer demotes separatorMode — each group carries its own. if (!backward && maxPrefixLength < input.length) { const trailing = input.substring(maxPrefixLength); if (/^[\s\p{P}]+$/u.test(trailing)) { maxPrefixLength = input.length; - separatorMode = "optionalSpace"; } } @@ -698,11 +682,22 @@ export class ConstructionCache { const noTrailingSeparator = !/[\s\p{P}]$/u.test(input); const directionSensitive = hasMatchedPart && noTrailingSeparator; + // Build per-mode groups. + const groups: CompletionGroup[] = []; + for (const [mode, completions] of modeCompletions) { + groups.push({ + name: "Construction Completions", + completions, + separatorMode: mode, + needQuotes: false, + kind: "literal", + }); + } + return { - completions: requestText, + groups, properties: completionProperty, matchedPrefixLength: maxPrefixLength, - separatorMode, closedSet, directionSensitive, }; diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index 8ccc28cf2..d6f64c0fe 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -6,6 +6,7 @@ import { WildcardMode, } from "../src/constructions/constructions.js"; import { + CompletionResult, ConstructionCache, MatchOptions, } from "../src/constructions/constructionCache.js"; @@ -16,6 +17,16 @@ import { TransformInfo, } from "../src/constructions/matchPart.js"; +// Helpers for backward-compat access to per-group CompletionResult +function flatCompletions(result: CompletionResult): string[] { + return result.groups.flatMap((g) => g.completions); +} +function firstSepMode(result: CompletionResult) { + return result.groups.length > 0 + ? result.groups[0].separatorMode + : undefined; +} + function makeTransformInfo(name: string): TransformInfo { return { namespace: "test", @@ -68,7 +79,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c1, c2]); const result = cache.completion("", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["play", "stop"]); + expect(flatCompletions(result!).sort()).toEqual(["play", "stop"]); expect(result!.matchedPrefixLength).toBe(0); expect(result!.closedSet).toBe(true); }); @@ -88,7 +99,7 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("", defaultOptions); expect(result).toBeDefined(); // Should return the first non-optional part's completions - expect(result!.completions).toEqual(["play"]); + expect(flatCompletions(result!)).toEqual(["play"]); }); }); @@ -104,7 +115,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); // The matcher consumes "play" (4 chars); the trailing space // is consumed as a trailing separator → matchedPrefixLength=5. expect(result!.matchedPrefixLength).toBe(5); @@ -119,7 +130,7 @@ describe("ConstructionCache.completion()", () => { // "pl" partially matches "play" const result = cache.completion("pl", defaultOptions); expect(result).toBeDefined(); - if (result!.completions.length > 0) { + if (flatCompletions(result!).length > 0) { expect(result!.matchedPrefixLength).toBeGreaterThanOrEqual(0); } }); @@ -189,7 +200,7 @@ describe("ConstructionCache.completion()", () => { // Property names → closedSet becomes false expect(result!.closedSet).toBe(false); // Should still include the literal completions from the matchSet - expect(result!.completions.sort()).toEqual(["pop", "rock"]); + expect(flatCompletions(result!).sort()).toEqual(["pop", "rock"]); }); }); @@ -207,9 +218,9 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - // The matcher consumes "play" (4 chars). Trailing space - // is consumed, so separatorMode is demoted to "optionalSpace". - expect(result!.separatorMode).toBe("optionalSpace"); + // The matcher consumes "play" (4 chars). With per-group + // separator modes, the grammar's native mode is preserved. + expect(firstSepMode(result!)).toBe("spacePunctuation"); }); it("returns spacePunctuation between adjacent word characters", () => { @@ -229,11 +240,11 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("play", defaultOptions); expect(result).toBeDefined(); if ( - result!.completions.length > 0 && + flatCompletions(result!).length > 0 && result!.matchedPrefixLength === 4 ) { // 'y' and 's' are both Latin word-boundary — needs separator - expect(result!.separatorMode).toBe("spacePunctuation"); + expect(firstSepMode(result!)).toBe("spacePunctuation"); } }); @@ -249,9 +260,9 @@ describe("ConstructionCache.completion()", () => { ); const cache = makeCache([c]); const result = cache.completion("hey! ", defaultOptions); - if (result && result.completions.length > 0) { + if (result && flatCompletions(result).length > 0) { // ' ' is not a word char → optional - expect(result!.separatorMode).toBe("optionalSpace"); + expect(firstSepMode(result!)).toBe("optionalSpace"); } }); @@ -268,11 +279,11 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("item3", defaultOptions); if ( result && - result.completions.length > 0 && + flatCompletions(result).length > 0 && result.matchedPrefixLength === 5 ) { // '3' and '4' are digits → needs separator - expect(result!.separatorMode).toBe("spacePunctuation"); + expect(firstSepMode(result!)).toBe("spacePunctuation"); } }); }); @@ -289,7 +300,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual([ + expect(flatCompletions(result!).sort()).toEqual([ "album", "song", "track", @@ -307,7 +318,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // Exact match advances maxPrefixLength to requestPrefix.length expect(result!.matchedPrefixLength).toBe(4); - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); }); it("returns completions from multiple constructions with same prefix length", () => { @@ -328,7 +339,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c1, c2]); const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["album", "song"]); + expect(flatCompletions(result!).sort()).toEqual(["album", "song"]); }); }); @@ -364,11 +375,11 @@ describe("ConstructionCache.completion()", () => { const r1 = cache.completion("", { namespaceKeys: ["ns1"] }); expect(r1).toBeDefined(); - expect(r1!.completions).toEqual(["play"]); + expect(flatCompletions(r1!)).toEqual(["play"]); const r2 = cache.completion("", { namespaceKeys: ["ns2"] }); expect(r2).toBeDefined(); - expect(r2!.completions).toEqual(["stop"]); + expect(flatCompletions(r2!)).toEqual(["stop"]); }); it("returns completions from all namespaces when no filter", () => { @@ -386,7 +397,7 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("", {}); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["play", "stop"]); + expect(flatCompletions(result!).sort()).toEqual(["play", "stop"]); }); it("returns no completions for empty namespace keys", () => { @@ -429,7 +440,7 @@ describe("ConstructionCache.completion()", () => { // "p" doesn't fully match "play" but the partial match // succeeds and returns the first part's candidates. // The caller filters by the remaining text ("p"). - expect(result!.completions).toContain("play"); + expect(flatCompletions(result!)).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); expect(result!.closedSet).toBe(true); }); @@ -437,27 +448,28 @@ describe("ConstructionCache.completion()", () => { it("prefix 'pl' — partial prefix returns first part completions", () => { const result = cache.completion("pl", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toContain("play"); + expect(flatCompletions(result!)).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); }); it("prefix 'play' — first part fully matched, offers second part", () => { const result = cache.completion("play", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toEqual(["song"]); + expect(flatCompletions(result!)).toEqual(["song"]); expect(result!.matchedPrefixLength).toBe(4); - expect(result!.separatorMode).toBe("spacePunctuation"); + expect(firstSepMode(result!)).toBe("spacePunctuation"); expect(result!.closedSet).toBe(true); }); it("prefix 'play ' — trailing space consumed, still offers second part", () => { const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toEqual(["song"]); - // Trailing space consumed → matchedPrefixLength advances to 5, - // separatorMode demoted to "optionalSpace". + expect(flatCompletions(result!)).toEqual(["song"]); + // Trailing space consumed → matchedPrefixLength advances to 5. + // With per-group separator modes, the grammar's native mode + // is preserved (no advance-1 demotion). expect(result!.matchedPrefixLength).toBe(5); - expect(result!.separatorMode).toBe("optionalSpace"); + expect(firstSepMode(result!)).toBe("spacePunctuation"); }); it("prefix 'play s' — partial intra-part on second part, returns completions", () => { @@ -465,14 +477,14 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // "play" is fully matched (4 chars), " s" remains as // partial prefix for the second part. - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(4); }); it("prefix 'play song' — exact full match, empty completions", () => { const result = cache.completion("play song", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); expect(result!.matchedPrefixLength).toBe(9); expect(result!.closedSet).toBe(true); }); @@ -491,11 +503,19 @@ describe("ConstructionCache.completion()", () => { const r1 = cache.completion("play", defaultOptions); expect(r1).toBeDefined(); - expect(r1!.completions.sort()).toEqual(["album", "song", "track"]); + expect(flatCompletions(r1!).sort()).toEqual([ + "album", + "song", + "track", + ]); const r2 = cache.completion("start", defaultOptions); expect(r2).toBeDefined(); - expect(r2!.completions.sort()).toEqual(["album", "song", "track"]); + expect(flatCompletions(r2!).sort()).toEqual([ + "album", + "song", + "track", + ]); }); }); @@ -511,7 +531,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("PLAY", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toEqual(["song"]); + expect(flatCompletions(result!)).toEqual(["song"]); expect(result!.matchedPrefixLength).toBe(4); }); }); @@ -529,7 +549,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("play the ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["album", "song"]); + expect(flatCompletions(result!).sort()).toEqual(["album", "song"]); }); it("returns merged match set completions after merge", () => { @@ -546,7 +566,7 @@ describe("ConstructionCache.completion()", () => { // After merge, the match set should contain both "play" and "stop" const result = cache.completion("", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["play", "stop"]); + expect(flatCompletions(result!).sort()).toEqual(["play", "stop"]); }); }); @@ -594,7 +614,7 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("play my song", defaultOptions); expect(result).toBeDefined(); // Wildcard consumes "my song" → exact match, no completions. - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); expect(result!.matchedPrefixLength).toBe(12); }); }); @@ -641,7 +661,7 @@ describe("ConstructionCache.completion()", () => { defaultOptions, ); expect(result).toBeDefined(); - expect(result!.completions).toContain("by"); + expect(flatCompletions(result!)).toContain("by"); expect(result!.matchedPrefixLength).toBe(14); }); @@ -662,7 +682,7 @@ describe("ConstructionCache.completion()", () => { defaultOptions, ); expect(result).toBeDefined(); - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); expect(result!.matchedPrefixLength).toBe(22); }); @@ -696,7 +716,7 @@ describe("ConstructionCache.completion()", () => { // so the matcher advances past it to offer "music". const result = cache.completion("play rock ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toContain("music"); + expect(flatCompletions(result!)).toContain("music"); }); it("offers wildcard-enabled part completions when literal doesn't match", () => { @@ -717,7 +737,10 @@ describe("ConstructionCache.completion()", () => { // literal matches and property names. const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual(["pop", "rock"]); + expect(flatCompletions(result!).sort()).toEqual([ + "pop", + "rock", + ]); expect(result!.closedSet).toBe(false); }); @@ -740,7 +763,7 @@ describe("ConstructionCache.completion()", () => { // text. The next literal "music" is offered. const result = cache.completion("play jazz ", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toContain("music"); + expect(flatCompletions(result!)).toContain("music"); }); }); @@ -773,7 +796,7 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("some song", defaultOptions); expect(result).toBeDefined(); - expect(result!.completions).toContain("by"); + expect(flatCompletions(result!)).toContain("by"); }); }); }); @@ -798,7 +821,7 @@ describe("ConstructionCache.completion()", () => { // Backs up to last part start ("play" consumed 4 // chars; the space is a separator, not part of any // match part). - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(4); }); @@ -815,7 +838,7 @@ describe("ConstructionCache.completion()", () => { ); expect(result).toBeDefined(); // Single part — backs up to 0 (the start of the only part). - expect(result!.completions).toContain("play"); + expect(flatCompletions(result!)).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); }); @@ -834,7 +857,7 @@ describe("ConstructionCache.completion()", () => { "forward", ); expect(result).toBeDefined(); - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); expect(result!.matchedPrefixLength).toBe(9); }); }); @@ -855,7 +878,7 @@ describe("ConstructionCache.completion()", () => { "backward", ); expect(result).toBeDefined(); - expect(result!.completions.sort()).toEqual([ + expect(flatCompletions(result!).sort()).toEqual([ "album", "song", "track", @@ -901,7 +924,7 @@ describe("ConstructionCache.completion()", () => { "forward", ); expect(result).toBeDefined(); - expect(result!.completions).toEqual([]); + expect(flatCompletions(result!)).toEqual([]); expect(result!.matchedPrefixLength).toBe(12); }); }); @@ -951,7 +974,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // No trailing space — backward backs up to offer // "play" at position 0. - expect(result!.completions).toContain("play"); + expect(flatCompletions(result!)).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); }); @@ -972,7 +995,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // Trailing space is a commit signal — direction no // longer matters. Should offer "song" same as forward. - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(5); }); @@ -991,7 +1014,7 @@ describe("ConstructionCache.completion()", () => { "forward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(5); }); }); @@ -1013,7 +1036,7 @@ describe("ConstructionCache.completion()", () => { "backward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(4); }); @@ -1033,7 +1056,7 @@ describe("ConstructionCache.completion()", () => { "forward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("now"); + expect(flatCompletions(result!)).toContain("now"); expect(result!.matchedPrefixLength).toBe(9); }); }); @@ -1059,7 +1082,7 @@ describe("ConstructionCache.completion()", () => { "backward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(4); }); }); @@ -1085,7 +1108,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // Trailing comma is a separator — commits "play". // Should offer "song" same as forward. - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(5); }); @@ -1104,7 +1127,7 @@ describe("ConstructionCache.completion()", () => { "backward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("song"); + expect(flatCompletions(result!)).toContain("song"); expect(result!.matchedPrefixLength).toBe(5); }); @@ -1123,7 +1146,7 @@ describe("ConstructionCache.completion()", () => { "backward", ); expect(result).toBeDefined(); - expect(result!.completions).toContain("play"); + expect(flatCompletions(result!)).toContain("play"); expect(result!.matchedPrefixLength).toBe(0); }); @@ -1144,7 +1167,7 @@ describe("ConstructionCache.completion()", () => { ); expect(result).toBeDefined(); // Trailing comma after second word commits "song". - expect(result!.completions).toContain("now"); + expect(flatCompletions(result!)).toContain("now"); expect(result!.matchedPrefixLength).toBe(10); }); }); @@ -1346,7 +1369,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); // c2 dominates at prefix length 9. expect(result!.directionSensitive).toBe(true); - expect(result!.completions).toContain("now"); + expect(flatCompletions(result!)).toContain("now"); }); it("not sensitive when trailing space commits across all constructions", () => { diff --git a/ts/packages/cache/test/crossGrammarConflict.spec.ts b/ts/packages/cache/test/crossGrammarConflict.spec.ts index 62b2f1928..7c931b34f 100644 --- a/ts/packages/cache/test/crossGrammarConflict.spec.ts +++ b/ts/packages/cache/test/crossGrammarConflict.spec.ts @@ -13,6 +13,12 @@ import { AgentCache } from "../src/cache/cache.js"; import { loadGrammarRules } from "action-grammar"; import { ExplainerFactory } from "../src/cache/factory.js"; +import { CompletionResult } from "../src/constructions/constructionCache.js"; + +// Helpers for per-group CompletionResult access +function flatCompletions(result: CompletionResult): string[] { + return result.groups.flatMap((g) => g.completions); +} const mockExplainerFactory: ExplainerFactory = () => { return { @@ -59,28 +65,29 @@ describe("Cross-grammar separator-mode conflict filtering", () => { return cache.completion(input, { namespaceKeys }, direction); } - test("'ab' no trailing separator: none-mode grammar survives, requiring dropped", () => { + test("'ab' no trailing separator: both grammars survive with per-group modes", () => { const result = complete("ab"); expect(result).toBeDefined(); - expect(result!.completions).toContain("cd"); - // closedSet forced false so shell re-fetches on sep change. - expect(result!.closedSet).toBe(false); - // separatorMode should be "none" (only none-grammar survived). - expect(result!.separatorMode).toBe("none"); + expect(flatCompletions(result!)).toContain("cd"); + // Both grammars survive — closedSet true (same completions). + expect(result!.closedSet).toBe(true); + // Two groups: one "none" (from noneSchema), one "spacePunctuation" (from autoSchema). + const modes = result!.groups.map((g) => g.separatorMode).sort(); + expect(modes).toEqual(["none", "spacePunctuation"]); }); - test("'ab ' trailing separator: requiring-mode grammar survives, none dropped", () => { + test("'ab ' trailing separator: both grammars survive with per-group modes", () => { const result = complete("ab "); expect(result).toBeDefined(); - expect(result!.completions).toContain("cd"); - expect(result!.closedSet).toBe(false); - // P advanced past the separator → separatorMode overridden to "optionalSpace". - expect(result!.separatorMode).toBe("optionalSpace"); - // matchedPrefixLength advanced by 1 past the trailing separator. - expect(result!.matchedPrefixLength).toBe(3); + expect(flatCompletions(result!)).toContain("cd"); + // Both grammars survive — closedSet true (same completions). + expect(result!.closedSet).toBe(true); + // Two groups with their respective separator modes. + const modes = result!.groups.map((g) => g.separatorMode).sort(); + expect(modes).toEqual(["none", "spacePunctuation"]); }); - test("afterWildcard 'all' downgraded to 'some' when grammars dropped", () => { + test("afterWildcard preserved when no grammars dropped", () => { // Use grammars with wildcards so afterWildcard can be "all". const cache2 = new AgentCache("test2", mockExplainerFactory, undefined); @@ -109,11 +116,10 @@ describe("Cross-grammar separator-mode conflict filtering", () => { ); const result = cache2.completion("hello", { namespaceKeys }); expect(result).toBeDefined(); - // If grammars were dropped and afterWildcard was "all", - // it should be downgraded to "some". - if (result!.afterWildcard !== "none") { - expect(result!.afterWildcard).not.toBe("all"); - } + // With no conflict filtering, afterWildcard is preserved as-is. + // Both grammars report afterWildcard and the merge keeps the + // widest value. + expect(result!.afterWildcard).toBeDefined(); }); test("no conflict when both grammars have compatible modes", () => { @@ -143,8 +149,8 @@ describe("Cross-grammar separator-mode conflict filtering", () => { const result = cache3.completion("ab", { namespaceKeys }); expect(result).toBeDefined(); // Both completions present — no filtering. - expect(result!.completions).toContain("cd"); - expect(result!.completions).toContain("ef"); + expect(flatCompletions(result!)).toContain("cd"); + expect(flatCompletions(result!)).toContain("ef"); // closedSet not forced false (no conflict). expect(result!.closedSet).toBe(true); }); diff --git a/ts/packages/cache/test/grammarIntegration.spec.ts b/ts/packages/cache/test/grammarIntegration.spec.ts index 4f63d9f9c..50f65d78c 100644 --- a/ts/packages/cache/test/grammarIntegration.spec.ts +++ b/ts/packages/cache/test/grammarIntegration.spec.ts @@ -720,8 +720,8 @@ describe("Grammar Integration", () => { // May or may not have completions depending on grammar structure // Main assertion is that completion() works without error in NFA mode - if (completions && completions.completions.length > 0) { - const completionStrings = completions.completions.map((c) => + if (completions && completions.groups.flatMap(g => g.completions).length > 0) { + const completionStrings = completions.groups.flatMap(g => g.completions).map((c) => c.toLowerCase(), ); console.log("NFA completions:", completionStrings); @@ -759,10 +759,10 @@ describe("Grammar Integration", () => { expect(completions).toBeDefined(); // Completion system exists and works in completion-based mode - if (completions && completions.completions.length > 0) { + if (completions && completions.groups.flatMap(g => g.completions).length > 0) { console.log( "Completion-based completions:", - completions.completions, + completions.groups.flatMap(g => g.completions), ); } }); @@ -881,7 +881,7 @@ describe("Grammar Integration", () => { // separator before "b" is stripped), NOT at 17 (EOI where // localPlayer's wildcard absorbs everything) expect(completions!.matchedPrefixLength).toBe(15); - expect(completions!.completions).toContain("by"); + expect(completions!.groups.flatMap(g => g.completions)).toContain("by"); }); }); diff --git a/ts/packages/cache/test/mergeCompletionResults.spec.ts b/ts/packages/cache/test/mergeCompletionResults.spec.ts index 1d32181f2..a05b280d5 100644 --- a/ts/packages/cache/test/mergeCompletionResults.spec.ts +++ b/ts/packages/cache/test/mergeCompletionResults.spec.ts @@ -6,6 +6,11 @@ import { mergeCompletionResults, } from "../src/constructions/constructionCache.js"; +// Helpers for per-group CompletionResult access +function flatCompletions(result: CompletionResult): string[] { + return result.groups.flatMap((g) => g.completions); +} + describe("mergeCompletionResults", () => { // Infinity as prefixLength disables the EOI-wildcard preference // logic (matchedLen >= Infinity is never true), letting tests @@ -22,7 +27,13 @@ describe("mergeCompletionResults", () => { it("returns first when second is undefined", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, }; const result = mergeCompletionResults(first, undefined, Infinity); @@ -31,7 +42,13 @@ describe("mergeCompletionResults", () => { it("returns second when first is undefined", () => { const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 3, }; const result = mergeCompletionResults(undefined, second, Infinity); @@ -40,106 +57,194 @@ describe("mergeCompletionResults", () => { it("discards shorter-prefix completions when second is longer", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 10, }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBe(10); - expect(result.completions).toEqual(["b"]); + expect(flatCompletions(result)).toEqual(["b"]); }); it("discards shorter-prefix completions when first is longer", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 12, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 3, }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBe(12); - expect(result.completions).toEqual(["a"]); + expect(flatCompletions(result)).toEqual(["a"]); }); it("merges completions when both have equal matchedPrefixLength", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBe(5); - expect(result.completions).toEqual(["a", "b"]); + expect(flatCompletions(result)).toEqual(["a", "b"]); }); it("returns undefined matchedPrefixLength when both are missing", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBeUndefined(); - expect(result.completions).toEqual(["a", "b"]); + expect(flatCompletions(result)).toEqual(["a", "b"]); }); it("discards second when only first has matchedPrefixLength", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 7, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBe(7); - expect(result.completions).toEqual(["a"]); + expect(flatCompletions(result)).toEqual(["a"]); }); it("discards first when only second has matchedPrefixLength", () => { const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 4, }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.matchedPrefixLength).toBe(4); - expect(result.completions).toEqual(["b"]); + expect(flatCompletions(result)).toEqual(["b"]); }); }); describe("completions merging", () => { it("merges completions from both results", () => { const first: CompletionResult = { - completions: ["a", "b"], + groups: [ + { + name: "test", + completions: ["a", "b"], + separatorMode: "space", + }, + ], }; const second: CompletionResult = { - completions: ["c", "d"], + groups: [ + { + name: "test", + completions: ["c", "d"], + separatorMode: "space", + }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.completions).toEqual(["a", "b", "c", "d"]); + expect(flatCompletions(result)).toEqual(["a", "b", "c", "d"]); }); it("handles empty completions", () => { const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const second: CompletionResult = { - completions: ["c"], + groups: [ + { + name: "test", + completions: ["c"], + separatorMode: "space", + }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.completions).toEqual(["c"]); + expect(flatCompletions(result)).toEqual(["c"]); }); }); @@ -154,11 +259,15 @@ describe("mergeCompletionResults", () => { names: ["name2"], }; const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], properties: [prop1], }; const second: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], properties: [prop2], }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -171,11 +280,15 @@ describe("mergeCompletionResults", () => { names: ["name1"], }; const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], properties: [prop1], }; const second: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.properties).toBe(first.properties); @@ -187,10 +300,14 @@ describe("mergeCompletionResults", () => { names: ["name2"], }; const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const second: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], properties: [prop2], }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -199,83 +316,108 @@ describe("mergeCompletionResults", () => { it("returns undefined properties when neither has them", () => { const first: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const second: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.properties).toBeUndefined(); }); }); - describe("separatorMode merging", () => { - it("returns undefined when neither has separatorMode", () => { - const first: CompletionResult = { completions: ["a"] }; - const second: CompletionResult = { completions: ["b"] }; - const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.separatorMode).toBeUndefined(); - }); - - it("returns first separatorMode when second is undefined", () => { - const first: CompletionResult = { - completions: ["a"], - separatorMode: "spacePunctuation", - }; - const second: CompletionResult = { completions: ["b"] }; - const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.separatorMode).toBe("spacePunctuation"); - }); - - it("returns second separatorMode when first is undefined", () => { - const first: CompletionResult = { completions: ["a"] }; - const second: CompletionResult = { - completions: ["b"], - separatorMode: "spacePunctuation", - }; - const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.separatorMode).toBe("spacePunctuation"); - }); - - it("returns most restrictive when both have separatorMode", () => { + describe("per-group separatorMode preservation", () => { + it("preserves per-group separatorMode when merging same-length results", () => { const first: CompletionResult = { - completions: ["a"], - separatorMode: "spacePunctuation", + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "spacePunctuation", + }, + ], + matchedPrefixLength: 5, }; const second: CompletionResult = { - completions: ["b"], - separatorMode: "optionalSpace", + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "optionalSpace", + }, + ], + matchedPrefixLength: 5, }; const result = mergeCompletionResults(first, second, Infinity)!; - expect(result.separatorMode).toBe("spacePunctuation"); + // Groups are concatenated, each preserving its own separatorMode. + expect(result.groups.length).toBe(2); + expect(result.groups[0].separatorMode).toBe("spacePunctuation"); + expect(result.groups[1].separatorMode).toBe("optionalSpace"); }); it("preserves separatorMode when first is undefined result", () => { const second: CompletionResult = { - completions: ["b"], - separatorMode: "spacePunctuation", + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "spacePunctuation", + }, + ], }; const result = mergeCompletionResults(undefined, second, Infinity); expect(result).toBe(second); - expect(result!.separatorMode).toBe("spacePunctuation"); + expect(result!.groups[0].separatorMode).toBe("spacePunctuation"); }); }); describe("closedSet merging", () => { it("returns undefined when neither has closedSet", () => { - const first: CompletionResult = { completions: ["a"] }; - const second: CompletionResult = { completions: ["b"] }; + const first: CompletionResult = { + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], + }; + const second: CompletionResult = { + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], + }; const result = mergeCompletionResults(first, second, Infinity)!; expect(result.closedSet).toBeUndefined(); }); it("returns true only when both are true", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: true, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: true, }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -284,11 +426,23 @@ describe("mergeCompletionResults", () => { it("returns false when first is true and second is false", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: true, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: false, }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -297,11 +451,23 @@ describe("mergeCompletionResults", () => { it("returns false when first is false and second is true", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: false, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: true, }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -310,11 +476,23 @@ describe("mergeCompletionResults", () => { it("returns false when both are false", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: false, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: false, }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -323,11 +501,23 @@ describe("mergeCompletionResults", () => { it("returns false when only first has closedSet=true and second is undefined", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: true, }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], }; const result = mergeCompletionResults(first, second, Infinity)!; // undefined treated as false → true && false = false @@ -336,10 +526,22 @@ describe("mergeCompletionResults", () => { it("returns false when only second has closedSet=true and first is undefined", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], }; const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: true, }; const result = mergeCompletionResults(first, second, Infinity)!; @@ -349,7 +551,13 @@ describe("mergeCompletionResults", () => { it("preserves closedSet when first result is undefined", () => { const second: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], closedSet: true, }; const result = mergeCompletionResults(undefined, second, Infinity); @@ -359,7 +567,13 @@ describe("mergeCompletionResults", () => { it("preserves closedSet when second result is undefined", () => { const first: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], closedSet: false, }; const result = mergeCompletionResults(first, undefined, Infinity); @@ -371,81 +585,137 @@ describe("mergeCompletionResults", () => { describe("open wildcard at EOI — prefer anchored result", () => { it("keeps shorter anchored result when longer is afterWildcard at EOI", () => { const anchored: CompletionResult = { - completions: ["by"], + groups: [ + { + name: "test", + completions: ["by"], + separatorMode: "space", + }, + ], matchedPrefixLength: 16, afterWildcard: "all", }; const eoi: CompletionResult = { - completions: ["track", "song"], + groups: [ + { + name: "test", + completions: ["track", "song"], + separatorMode: "space", + }, + ], matchedPrefixLength: 17, afterWildcard: "all", }; // prefixLength=17: eoi is at EOI, anchored is inside input const result = mergeCompletionResults(anchored, eoi, 17)!; - expect(result.completions).toEqual(["by"]); + expect(flatCompletions(result)).toEqual(["by"]); expect(result.matchedPrefixLength).toBe(16); }); it("keeps shorter anchored result regardless of argument order", () => { const anchored: CompletionResult = { - completions: ["by"], + groups: [ + { + name: "test", + completions: ["by"], + separatorMode: "space", + }, + ], matchedPrefixLength: 16, afterWildcard: "all", }; const eoi: CompletionResult = { - completions: ["track", "song"], + groups: [ + { + name: "test", + completions: ["track", "song"], + separatorMode: "space", + }, + ], matchedPrefixLength: 17, afterWildcard: "all", }; // Reversed argument order const result = mergeCompletionResults(eoi, anchored, 17)!; - expect(result.completions).toEqual(["by"]); + expect(flatCompletions(result)).toEqual(["by"]); expect(result.matchedPrefixLength).toBe(16); }); it("still prefers longer when both are below EOI", () => { const shorter: CompletionResult = { - completions: ["a"], + groups: [ + { + name: "test", + completions: ["a"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, afterWildcard: "all", }; const longer: CompletionResult = { - completions: ["b"], + groups: [ + { + name: "test", + completions: ["b"], + separatorMode: "space", + }, + ], matchedPrefixLength: 10, afterWildcard: "all", }; const result = mergeCompletionResults(shorter, longer, 20)!; - expect(result.completions).toEqual(["b"]); + expect(flatCompletions(result)).toEqual(["b"]); expect(result.matchedPrefixLength).toBe(10); }); it("still prefers longer when longer is at EOI but shorter has no completions", () => { const shorter: CompletionResult = { - completions: [], + groups: [ + { name: "test", completions: [], separatorMode: "space" }, + ], matchedPrefixLength: 0, }; const eoi: CompletionResult = { - completions: ["track"], + groups: [ + { + name: "test", + completions: ["track"], + separatorMode: "space", + }, + ], matchedPrefixLength: 17, afterWildcard: "all", }; const result = mergeCompletionResults(shorter, eoi, 17)!; - expect(result.completions).toEqual(["track"]); + expect(flatCompletions(result)).toEqual(["track"]); expect(result.matchedPrefixLength).toBe(17); }); it('still prefers longer when afterWildcard is "none"', () => { const anchored: CompletionResult = { - completions: ["by"], + groups: [ + { + name: "test", + completions: ["by"], + separatorMode: "space", + }, + ], matchedPrefixLength: 16, }; const eoi: CompletionResult = { - completions: ["track"], + groups: [ + { + name: "test", + completions: ["track"], + separatorMode: "space", + }, + ], matchedPrefixLength: 17, afterWildcard: "none", }; const result = mergeCompletionResults(anchored, eoi, 17)!; - expect(result.completions).toEqual(["track"]); + expect(flatCompletions(result)).toEqual(["track"]); expect(result.matchedPrefixLength).toBe(17); }); }); diff --git a/ts/packages/cli/src/commands/connect.ts b/ts/packages/cli/src/commands/connect.ts index eb65352dd..c165a67b9 100644 --- a/ts/packages/cli/src/commands/connect.ts +++ b/ts/packages/cli/src/commands/connect.ts @@ -48,11 +48,12 @@ async function getCompletionsData( const filterStartIndex = result.startIndex; const prefix = line.substring(0, filterStartIndex); - const separator = - result.separatorMode === "space" || - result.separatorMode === "spacePunctuation" - ? " " - : ""; + const needsSep = result.completions.some( + (g) => + g.separatorMode === "space" || + g.separatorMode === "spacePunctuation", + ); + const separator = needsSep ? " " : ""; return { allCompletions, diff --git a/ts/packages/cli/src/commands/interactive.ts b/ts/packages/cli/src/commands/interactive.ts index 04d6a4090..e7c173e4a 100644 --- a/ts/packages/cli/src/commands/interactive.ts +++ b/ts/packages/cli/src/commands/interactive.ts @@ -79,14 +79,15 @@ async function getCompletionsData( const filterStartIndex = result.startIndex; const prefix = line.substring(0, filterStartIndex); - // When the result reports a separator-requiring mode between the + // When any group reports a separator-requiring mode between the // typed prefix and the completion text, prepend a space so the // readline display doesn't produce "playmusic" for "play" + "music". - const separator = - result.separatorMode === "space" || - result.separatorMode === "spacePunctuation" - ? " " - : ""; + const needsSep = result.completions.some( + (g) => + g.separatorMode === "space" || + g.separatorMode === "spacePunctuation", + ); + const separator = needsSep ? " " : ""; return { allCompletions, diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 8c3fff555..53f81ef4c 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -18,7 +18,6 @@ import { import { getFlagMultiple, getFlagType, - mergeSeparatorMode, resolveFlag, } from "@typeagent/agent-sdk/helpers/command"; import { parseParams, ParseParamsResult } from "./parameters.js"; @@ -415,6 +414,7 @@ async function getCommandParameterCompletion( completions.push({ name: target.booleanFlagName, completions: ["true", "false"], + separatorMode: target.separatorMode, }); } if (target.includeFlags && descriptor.parameters.flags !== undefined) { @@ -427,6 +427,7 @@ async function getCommandParameterCompletion( completions.push({ name: "Command Flags", completions: flagCompletions, + separatorMode: target.separatorMode, }); } } @@ -438,7 +439,6 @@ async function getCommandParameterCompletion( // full list of names to complete. let agentInvoked = false; let agentClosedSet: boolean | undefined; - let separatorMode: SeparatorMode | undefined = target.separatorMode; let directionSensitive = false; let afterWildcard: AfterWildcard = "none"; @@ -468,9 +468,6 @@ async function getCommandParameterCompletion( if (groupPrefixLength !== undefined && groupPrefixLength !== 0) { startIndex = target.startIndex + groupPrefixLength; completions.length = 0; // grammar overrides built-in completions - // The agent advanced the prefix — it is authoritative for - // the separator at this position. - separatorMode = agentResult.separatorMode; } completions.push(...agentResult.groups); agentInvoked = true; @@ -489,7 +486,6 @@ async function getCommandParameterCompletion( return { completions, startIndex, - separatorMode, closedSet: computeClosedSet( agentInvoked, agentClosedSet, @@ -513,13 +509,11 @@ async function completeDescriptor( ): Promise<{ completions: CompletionGroup[]; startIndex: number | undefined; - separatorMode: SeparatorMode | undefined; closedSet: boolean; directionSensitive: boolean; afterWildcard: AfterWildcard; }> { const completions: CompletionGroup[] = []; - let separatorMode: SeparatorMode | undefined; const parameterCompletions = await getCommandParameterCompletion( descriptor, @@ -548,14 +542,12 @@ async function completeDescriptor( name: "Subcommands", completions: Object.keys(table!.commands), }); - separatorMode = mergeSeparatorMode(separatorMode, "space"); } if (parameterCompletions === undefined) { return { completions, startIndex: undefined, - separatorMode, closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -566,10 +558,6 @@ async function completeDescriptor( return { completions, startIndex: parameterCompletions.startIndex, - separatorMode: mergeSeparatorMode( - separatorMode, - parameterCompletions.separatorMode, - ), closedSet: parameterCompletions.closedSet, directionSensitive: parameterCompletions.directionSensitive, afterWildcard: parameterCompletions.afterWildcard, @@ -628,12 +616,9 @@ async function completeDescriptor( // (b) flag names from the descriptor's ParameterDefinitions, // (c) agent-provided groups via the agent's // getCommandCompletion callback. -// -// separatorMode -// Describes what kind of separator is required between -// the matched prefix and the completion text. -// Merged: most restrictive mode from any source wins. -// When omitted, consumers default to "space". +// Each CompletionGroup carries its own separatorMode +// describing what separator is required before that +// group's completions. // // closedSet true when the returned completions form a *closed set* // of valid continuations after the prefix. When true @@ -666,18 +651,7 @@ export async function getCommandCompletion( ); let startIndex = commandConsumedLength; - // Collect completions and track separatorMode across all sources. - // When trailing whitespace was consumed *and* nothing follows - // (suffix is empty), separatorMode starts at "optionalSpace" — the - // space is already part of the anchor so no additional separator - // is needed. When a suffix exists (e.g. "--off"), the space - // before it is structural, not trailing. const completions: CompletionGroup[] = []; - let separatorMode: SeparatorMode | undefined = - result.suffix.length === 0 && - hasWhitespaceBefore(input, commandConsumedLength) - ? "optionalSpace" - : undefined; let closedSet = true; // Track whether direction influenced the result. When false, // the caller can skip re-fetching on direction change. @@ -717,8 +691,8 @@ export async function getCommandCompletion( completions.push({ name: "Subcommands", completions: Object.keys(table!.commands), + separatorMode: "none", }); - separatorMode = mergeSeparatorMode(separatorMode, "none"); directionSensitive = true; // closedSet stays true: subcommand names are exhaustive. } else if (descriptor !== undefined) { @@ -734,10 +708,6 @@ export async function getCommandCompletion( if (desc.startIndex !== undefined) { startIndex = desc.startIndex; } - separatorMode = mergeSeparatorMode( - separatorMode, - desc.separatorMode, - ); closedSet = desc.closedSet; // Direction-sensitive if the command level is (would have // taken the reconsideringCommand branch with opposite @@ -762,14 +732,12 @@ export async function getCommandCompletion( completions.push({ name: "Subcommands", completions: Object.keys(table.commands), - }); - separatorMode = mergeSeparatorMode( - separatorMode, - result.parsedAppAgentName !== undefined || + separatorMode: + result.parsedAppAgentName !== undefined || result.commands.length > 0 - ? "space" - : "optionalSpace", - ); + ? "space" + : "optionalSpace", + }); } else { // Both table and descriptor are undefined — the agent // returned no commands at all. Nothing to add; @@ -790,8 +758,8 @@ export async function getCommandCompletion( completions: context.agents .getAppAgentNames() .filter((name) => context.agents.isCommandEnabled(name)), + separatorMode: "optionalSpace", }); - separatorMode = mergeSeparatorMode(separatorMode, "optionalSpace"); } if (startIndex === 0) { @@ -799,15 +767,12 @@ export async function getCommandCompletion( completions.push({ name: "Command Prefixes", completions: ["@"], + separatorMode: "optionalSpace", }); - - // The first token doesn't require separator before it - separatorMode = "optionalSpace"; } const completionResult: CommandCompletionResult = { startIndex, completions, - separatorMode, closedSet, directionSensitive, afterWildcard, @@ -822,7 +787,6 @@ export async function getCommandCompletion( return { startIndex: 0, completions: [], - separatorMode: undefined, closedSet: false, directionSensitive: false, afterWildcard: "none", diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts index 30b7f1e68..c91a0bf71 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -65,7 +65,6 @@ export class MatchCommandHandler implements CommandHandler { ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; - result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; result.directionSensitive = requestResult.directionSensitive; result.afterWildcard = requestResult.afterWildcard; diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts index 1c20d78cb..ff1231c86 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -514,7 +514,6 @@ export class RequestCommandHandler implements CommandHandler { ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; - result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; result.directionSensitive = requestResult.directionSensitive; result.afterWildcard = requestResult.afterWildcard; diff --git a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts index cb241e411..a009c2a5f 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -90,7 +90,6 @@ export class TranslateCommandHandler implements CommandHandler { ); result.groups.push(...requestResult.groups); result.matchedPrefixLength = requestResult.matchedPrefixLength; - result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; result.directionSensitive = requestResult.directionSensitive; result.afterWildcard = requestResult.afterWildcard; diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index f459ad473..f2b423467 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -98,25 +98,16 @@ export async function requestCompletion( } const matchedPrefixLength = results.matchedPrefixLength; - const separatorMode = results.separatorMode; const closedSet = results.closedSet; const directionSensitive = results.directionSensitive; const afterWildcard = results.afterWildcard; - const completions: CompletionGroup[] = []; - if (results.completions.length > 0) { - completions.push({ - name: "Request Completions", - completions: results.completions, - needQuotes: false, // Request completions are partial, no quotes needed - kind: "literal", - }); - } + // Groups already carry per-group separatorMode from the cache layer. + const completions: CompletionGroup[] = [...results.groups]; if (results.properties === undefined) { return { groups: completions, matchedPrefixLength, - separatorMode, closedSet, directionSensitive, afterWildcard, @@ -143,7 +134,6 @@ export async function requestCompletion( return { groups: completions, matchedPrefixLength, - separatorMode, closedSet, directionSensitive, afterWildcard, @@ -177,6 +167,7 @@ async function collectActionCompletions( propertyCompletions.set(propertyName, { name: `property ${propertyName}`, ...paramCompletion, + separatorMode: "space", needQuotes: false, // Request completions are partial, no quotes needed sorted: true, // REVIEW: assume property completions are already in desired order by the agent. kind: "entity", diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 632b6edc6..f47b1eb5b 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -41,7 +41,6 @@ function grammarCompletion(token: string): CompletionGroups { }, ], matchedPrefixLength: quoteOffset + 5, - separatorMode: "space", }; } if (text.startsWith("東京")) { @@ -54,10 +53,10 @@ function grammarCompletion(token: string): CompletionGroups { { name: "Grammar", completions: ["タワー", "駅"], + separatorMode: "optionalSpace", }, ], matchedPrefixLength: quoteOffset + 2, - separatorMode: "optionalSpace", }; } // No prefix matched — offer initial completions. @@ -69,7 +68,6 @@ function grammarCompletion(token: string): CompletionGroups { }, ], ...(token.length > 0 ? { matchedPrefixLength: 0 } : {}), - separatorMode: "space", }; } @@ -364,10 +362,10 @@ const handlers = { { name: "Keywords", completions: ["by", "from"], + separatorMode: "spacePunctuation", }, ], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -804,7 +802,7 @@ describe("Command Completion - startIndex", () => { // "run" is the default subcommand, so subcommand alternatives // are included and the group has separatorMode: "space". // Subcommand completions at the boundary retain "space". - expect(result!.separatorMode).toBe("space"); + expect(result!.completions[0].separatorMode).toBe("space"); // startIndex includes trailing whitespace. expect(result!.startIndex).toBe(10); }); @@ -816,8 +814,7 @@ describe("Command Completion - startIndex", () => { context, ); expect(result).toBeDefined(); - expect(result!.separatorMode).toBe("space"); - // No trailing whitespace to trim — startIndex stays at end + expect(result!.completions[0].separatorMode).toBe("space"); expect(result!.startIndex).toBe(9); // Default subcommand has agent completions → not exhaustive. expect(result!.closedSet).toBe(false); @@ -827,16 +824,14 @@ describe("Command Completion - startIndex", () => { const result = await getCommandCompletion("@", "forward", context); expect(result).toBeDefined(); // Top-level completions (agent names, system subcommands) - // follow '@' — space is accepted but not required. - expect(result!.separatorMode).toBe("optionalSpace"); - // Agent names are offered when no agent was recognized, - // independent of which branch (descriptor/table/neither) - // produced the subcommand completions. - const agentGroup = result!.completions.find( + // follow '@' — baseMode is "space" since there's no trailing + // whitespace after the '@' marker. + const agentNamesGroup = result!.completions.find( (g) => g.name === "Agent Names", ); - expect(agentGroup).toBeDefined(); - expect(agentGroup!.completions).toContain("comptest"); + expect(agentNamesGroup).toBeDefined(); + expect(agentNamesGroup!.separatorMode).toBe("space"); + expect(agentNamesGroup!.completions).toContain("comptest"); // Subcommand + agent name sets are finite → exhaustive. expect(result!.closedSet).toBe(true); }); @@ -849,9 +844,8 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // Partial parameter token — only parameter completions returned, - // no subcommand group. separatorMode set to "optionalSpace" - // due to trailing space advancement. - expect(result!.separatorMode).toBe("optionalSpace"); + // no subcommand group. Each group carries its own separatorMode. + expect(result!.completions[0].separatorMode).toBe("space"); }); it("returns no separatorMode for partial unmatched token consumed as param", async () => { @@ -866,11 +860,11 @@ describe("Command Completion - startIndex", () => { // (after "@comptest "), which is ≤ commandConsumedLength // (10), so sibling subcommands are included with // separatorMode="space". - expect(result!.separatorMode).toBe("space"); const subcommands = result!.completions.find( (g) => g.name === "Subcommands", ); expect(subcommands).toBeDefined(); + expect(subcommands!.separatorMode).toBe("space"); expect(result!.startIndex).toBe(10); }); }); diff --git a/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts index d0fc07698..f1bd07e27 100644 --- a/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts @@ -50,9 +50,14 @@ describe("Request handler completion propagation", () => { it('propagates afterWildcard="all" from cache through request handler', async () => { patchCacheCompletion({ - completions: ["by", "from"], + groups: [ + { + name: "Request Completions", + completions: ["by", "from"], + separatorMode: "spacePunctuation", + }, + ], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -67,14 +72,19 @@ describe("Request handler completion propagation", () => { expect(result.afterWildcard).toBe("all"); expect(result.directionSensitive).toBe(true); expect(result.closedSet).toBe(true); - expect(result.separatorMode).toBe("spacePunctuation"); + expect(result.completions[0].separatorMode).toBe("spacePunctuation"); }); it('propagates afterWildcard="none" from cache through request handler', async () => { patchCacheCompletion({ - completions: ["music"], + groups: [ + { + name: "Request Completions", + completions: ["music"], + separatorMode: "spacePunctuation", + }, + ], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -99,7 +109,13 @@ describe("Request handler completion propagation", () => { it("propagates directionSensitive through request handler", async () => { patchCacheCompletion({ - completions: ["by"], + groups: [ + { + name: "Request Completions", + completions: ["by"], + separatorMode: "space", + }, + ], matchedPrefixLength: 5, closedSet: false, directionSensitive: true, @@ -113,7 +129,13 @@ describe("Request handler completion propagation", () => { it("propagates closedSet from cache through request handler", async () => { patchCacheCompletion({ - completions: ["by", "from"], + groups: [ + { + name: "Request Completions", + completions: ["by", "from"], + separatorMode: "space", + }, + ], matchedPrefixLength: 10, closedSet: true, afterWildcard: "all", diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 80489f02c..9cabc3527 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -6,7 +6,6 @@ import { CompletionGroup, DisplayType, DynamicDisplay, - SeparatorMode, TemplateSchema, TypeAgentAction, AfterWildcard, @@ -81,10 +80,6 @@ export type CommandCompletionResult = { // resolved; completions describe what can follow after that prefix. startIndex: number; completions: CompletionGroup[]; // completions available at the current position - // What kind of separator is required between the matched prefix and - // the completion text. When omitted, defaults to "space". - // See SeparatorMode in @typeagent/agent-sdk. - separatorMode?: SeparatorMode | undefined; // True when the completions form a closed set — if the user types // something not in the list, no further completions can exist // beyond it. When true and the user types something that doesn't diff --git a/ts/packages/shell/src/preload/electronTypes.ts b/ts/packages/shell/src/preload/electronTypes.ts index c6cbbf05a..abc6a199b 100644 --- a/ts/packages/shell/src/preload/electronTypes.ts +++ b/ts/packages/shell/src/preload/electronTypes.ts @@ -39,10 +39,10 @@ export type SearchMenuPosition = { export type SearchMenuItem = { matchText: string; - emojiChar?: string; + emojiChar?: string | undefined; sortIndex?: number; selectedText: string; - needQuotes?: boolean; // default is true, and will add quote to the selectedText if it has spaces. + needQuotes?: boolean | undefined; // default is true, and will add quote to the selectedText if it has spaces. }; export type SearchMenuUIUpdateData = { diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 353d79368..a198a76b2 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -67,32 +67,26 @@ function computeNoMatchPolicy( // PENDING anchor !== undefined && completionP !== undefined // ACTIVE anchor !== undefined && completionP === undefined // +// Two-anchor model: +// - `anchor` (data validity): the prefix for which the backend result was +// computed. Past it → re-fetch. +// - `menuSepLevel` (trie matching level): items in the trie correspond to +// one SepLevel (0/1/2). The trie is only reloaded when the user narrows +// past the menu anchor or the menu is exhausted and a higher level exists. +// // Design principles: -// - Completion result fields (separatorMode, etc.) are stored as-is -// from the backend response and never mutated as the user keeps typing. -// reuseSession() reads them to decide whether to show, hide, or re-fetch. -// - reuseSession() makes exactly four kinds of decisions: -// 1. Re-fetch — input has moved past what the current result covers -// 2. Show/update menu — input satisfies the result's constraints; -// trie filters the loaded completions against the typed prefix. -// 3. Hide menu, keep session — input is within the anchor but the -// result's constraints aren't satisfied yet (separator not typed, -// or no completions exist). A re-fetch would return the same result. -// 4. Uniquely satisfied — the user has exactly typed one completion -// entry (and it is not a prefix of any other). Always re-fetches -// for the NEXT level's completions — the direction to use for the -// re-fetch is determined by the caller. -// - The `noMatchPolicy` controls the no-match fallthrough: when the trie -// has zero matches for the typed prefix: -// "accept" → nothing else exists, stay quiet -// "refetch" → backend may know more, re-fetch +// - Completion result fields (per-group separatorMode, etc.) are stored as-is +// from the backend response and never mutated. +// - Per-group separatorMode determines which groups belong to each SepLevel. +// Non-cumulative: each level has its own set of items. Widening replaces +// the trie rather than appending. +// - reuseSession() follows a decision table: A (session validity), +// B (menu anchor), C (trie matching), D (exhaustion cascade). +// See the method comment for the full table. +// - The `noMatchPolicy` controls the no-match fallthrough at D2–D4: +// "accept" → exhaustive set, stay quiet +// "refetch" → open-ended, backend may know more // "slide" → wildcard boundary, slide anchor forward -// - The anchor is never advanced after a result is received (except -// when noMatchPolicy is "slide", which slides the anchor forward). -// When `separatorMode` requires a separator, or is "optionalSpace" / -// "optionalSpacePunctuation", leading separator characters in the -// raw prefix are stripped before being passed to the menu, so the -// trie still matches. // // Architecture: docs/architecture/completion.md — §5 Shell — Completion Session // This class has no DOM dependencies and is fully unit-testable with Jest. @@ -102,8 +96,14 @@ export class PartialCompletionSession { // the backend reports how much the grammar consumed. `undefined` = IDLE. private anchor: string | undefined = undefined; - // Saved as-is from the last completion result. - private separatorMode: SeparatorMode = "space"; + // Items partitioned by separatorMode from the last result. + private partitions: ItemPartition[] = []; + // The SepLevel at which the trie is currently loaded. + // Items in the trie correspond to itemsAtLevel(partitions, menuSepLevel). + // The trie is only reloaded when: (a) the user narrows past the + // menu anchor (sepLevel < menuSepLevel), or (b) the menu is + // exhausted and a higher level is available (widen). + private menuSepLevel: SepLevel = 0; // Computed from the backend's closedSet + afterWildcard fields. // Controls what happens when the local trie has no matches. private noMatchPolicy: NoMatchPolicy = "refetch"; @@ -124,6 +124,31 @@ export class PartialCompletionSession { private readonly dispatcher: ICompletionDispatcher, ) {} + // Load the trie with items for the given level and set menuSepLevel. + // Shared by reuseSession (narrow/widen), startNewSession (initial load), + // and explicitHide (level change on dismiss). + private loadLevel(level: SepLevel): void { + this.menuSepLevel = level; + this.menu.setChoices(itemsAtLevel(this.partitions, level)); + } + + // Strip the separator from rawPrefix at the current menuSepLevel, + // compute the menu position, and update the trie prefix. Returns + // true when the prefix uniquely satisfies one entry; hides the menu + // when position cannot be determined. + private positionMenu( + rawPrefix: string, + getPosition: (prefix: string) => SearchMenuPosition | undefined, + ): boolean { + const completionPrefix = stripAtLevel(rawPrefix, this.menuSepLevel); + const position = getPosition(completionPrefix); + if (position !== undefined) { + return this.menu.updatePrefix(completionPrefix, position); + } + this.menu.hide(); + return false; + } + // Main entry point. Called by PartialCompletion.update() after DOM checks pass. // input: trimmed input text (ghost text stripped, leading whitespace stripped) // direction: host-provided signal: "forward" (user is moving ahead) or @@ -156,35 +181,55 @@ export class PartialCompletionSession { public resetToIdle(): void { this.anchor = undefined; this.completionP = undefined; + this.partitions = []; + this.menuSepLevel = 0; this.explicitCloseAnchor = undefined; } // Called when the user explicitly dismisses the menu (e.g. Escape key). - // Hides the menu and — when conditions allow — issues a background refetch - // with the full current input. The menu is only reopened if the backend - // returns a different anchor (startIndex changed), indicating the grammar - // found a new parse point. If the anchor is unchanged the completions - // would be the same ones the user just dismissed, so reopening is suppressed. // - // Conditions where refetch is skipped (result guaranteed identical): - // IDLE — no active session - // input===anchor — no prefix typed; same input was already fetched - // noMatchPolicy !== "refetch": - // "accept" — closed set; backend cannot offer more completions - // "slide" — wildcard boundary; refetch returns same anchor shifted + // Three outcomes: + // 1. Level shift — a different SepLevel has items the user hasn't + // seen. Shift the trie and show the new items (no backend call). + // 2. No advance — IDLE or input equals anchor. A refetch would + // return identical data. Just hide the menu. + // 3. Hide/slide — noMatchPolicy is "accept" or "slide" and the + // input still extends the anchor. No refetch can help. + // 4. Refetch — input advanced past the anchor at the same level + // and noMatchPolicy allows it. When the backend returns the + // same anchor (startIndex unchanged), reopening is suppressed. public explicitHide( input: string, getPosition: (prefix: string) => SearchMenuPosition | undefined, direction: CompletionDirection, ): void { this.completionP = undefined; // cancel any in-flight fetch - this.menu.hide(); - if ( - this.anchor === undefined || - input === this.anchor || - this.noMatchPolicy !== "refetch" - ) { + // IDLE — no session data, nothing to shift or refetch. + if (this.anchor === undefined) { + this.menu.hide(); + return; + } + + // If a different SepLevel is reachable, shift to it — the user + // sees new items without a backend round-trip. + if (input.startsWith(this.anchor)) { + const rawPrefix = input.substring(this.anchor.length); + const sepLevel = computeSepLevel(rawPrefix); + if (sepLevel !== this.menuSepLevel) { + const newLevel = targetLevel(this.partitions, sepLevel); + if (newLevel !== this.menuSepLevel) { + this.loadLevel(newLevel); + this.positionMenu(rawPrefix, getPosition); + return; + } + } + } + + // No level shift available. If input hasn't advanced past + // the anchor, a refetch would return identical results — just hide. + if (input === this.anchor || this.noMatchPolicy !== "refetch") { + this.menu.hide(); return; } @@ -194,114 +239,73 @@ export class PartialCompletionSession { } // Returns the text typed after the anchor, or undefined when - // the input has diverged past the anchor or the separator is not yet present. + // the input has diverged past the anchor or no items are loaded. public getCompletionPrefix(input: string): string | undefined { const anchor = this.anchor; if (anchor === undefined || !input.startsWith(anchor)) { return undefined; } const rawPrefix = input.substring(anchor.length); - const sepMode = this.separatorMode; - if (requiresSeparator(sepMode)) { - // The separator must be present and is not part of the replaceable prefix. - if (!separatorRegex(sepMode).test(rawPrefix)) { - return undefined; - } + if (computeSepLevel(rawPrefix) < this.menuSepLevel) { + return undefined; + } + if (itemsAtLevel(this.partitions, this.menuSepLevel).length === 0) { + return undefined; } - return stripLeadingSeparator(rawPrefix, sepMode); + return stripAtLevel(rawPrefix, this.menuSepLevel); } // Decides whether the current session can service `input` without a new // backend fetch. Returns true to reuse, false to trigger a re-fetch. // - // Decision order: - // PENDING — a fetch is in flight; wait for it (return true, no-op). - // RE-FETCH — input has moved outside the anchor; the saved result no - // longer applies (return false). - // HIDE+KEEP — input is within the anchor but the separator hasn't - // been typed yet; hide the menu but don't re-fetch - // (return true). - // UNIQUE — prefix exactly matches one entry and is not a prefix of - // any other; re-fetch for the NEXT level (return false). - // SHOW — constraints satisfied; update the menu. The final - // decision uses `noMatchPolicy`: - // reuse when the trie still has matches. When the trie - // is empty: "accept" → reuse, "refetch" → re-fetch, - // "slide" → slide anchor forward. + // Decision table (two-anchor model — see docs/architecture/completion.md): // - // Re-fetch triggers (returns false → startNewSession): + // A. Session validity — is the data still usable? + // A1 PENDING completionP !== undefined → wait + // A2 IDLE anchor === undefined → re-fetch + // A3 DIVERGED !input.startsWith(anchor) → re-fetch + // A4 DIR-SENS backward + dirSensitive + input===anchor → re-fetch // - // A. Session invalidation — anchor is stale; backend result was computed - // for a prefix that no longer matches the input. Unconditional. - // 1. No session — anchor is undefined (IDLE state). - // 2. Anchor diverged — input no longer starts with the saved anchor - // (e.g. backspace deleted into the anchor region). - // 3. Bad separator — separatorMode requires whitespace (or punctuation) - // immediately after anchor, but a non-separator - // character was typed instead. The constraint can - // never be satisfied, so treat as new input. - // 4. Direction changed — the user switched between forward and backward - // AND the last result was direction-sensitive - // AND the input is at the exact anchor (no text - // typed past it). Once the user types past the - // anchor, the direction-sensitive boundary has been - // passed and the loaded completions are still valid. + // B. Menu anchor — is the trie at the right level? + // B1 NARROW sepLevel < menuSepLevel + items at sepLevel → narrow, → C + // B2 BEFORE-MENU sepLevel < menuSepLevel + no items → hide+keep // - // B. Hierarchical navigation — user completed this level; re-fetch for - // the NEXT level's completions. - // 1. Uniquely satisfied — typed prefix exactly matches one completion and - // is not a prefix of any other. Always re-fetch - // for the NEXT level. - // 2. Committed past boundary — prefix contains a separator after a valid - // completion match (e.g. "set " where "set" matches - // but so does "setWindowState"). The user committed - // by typing a separator; re-fetch for next level. + // C. Trie matching — does the menu have results? + // C1 UNIQUE uniquelySatisfied → re-fetch + // C2 COMMITTED committed past boundary → re-fetch + // C3 ACTIVE menu.isActive() → reuse // - // C. Open-set discovery — trie has zero matches and the set is not - // exhaustive; the backend may know about completions not yet loaded. - // Gated by closedSet === false. - // 1. No matches + open set — trie has zero matches for the typed prefix - // AND noMatchPolicy is "refetch". The backend may - // know about completions not yet loaded. - private reuseSession( + // D. Exhaustion cascade — menu has no matches + // D1 WIDEN sepLevel > menuSepLevel → widen, → C + // D2 SLIDE noMatchPolicy=slide → slide anchor + // D3 REFETCH noMatchPolicy=refetch → re-fetch + // D4 ACCEPT noMatchPolicy=accept → reuse (quiet) + // ── A. Session validity ───────────────────────────────────────── + // Returns the anchor when the session is active and valid for + // the given input, or `undefined` when a re-fetch is needed. + // Checks A2 (IDLE), A3 (DIVERGED), A4 (DIR-SENS). + // Caller must check A1 (PENDING) before calling. + private getActiveAnchor( input: string, - getPosition: (prefix: string) => SearchMenuPosition | undefined, direction: CompletionDirection, - ): boolean { - // [A1] No session — IDLE state, must fetch. + ): string | undefined { + // [A2] IDLE — no session, must fetch. if (this.anchor === undefined) { debug(`Partial completion re-fetch: no active session (IDLE)`); - return false; + return undefined; } - // PENDING — a fetch is already in flight. - if (this.completionP !== undefined) { - debug(`Partial completion pending: ${this.anchor}`); - return true; + const { anchor } = this; + + // [A3] DIVERGED — input moved past the anchor. + if (!input.startsWith(anchor)) { + debug( + `Partial completion re-fetch: anchor diverged (anchor='${anchor}', input='${input}')`, + ); + return undefined; } - // ACTIVE from here. - const { anchor, separatorMode: sepMode, noMatchPolicy } = this; - - // [A4] Backward at a direction-sensitive anchor. - // The two-pass backward approach means the loaded result is - // always a forward-at-P result. When the user is at the - // anchor going backward, the loaded forward result does not - // apply — re-fetch to get backward's backed-up result. - // - // Only backward needs this check: forward at the anchor - // matches the loaded result (which is forward-at-P). - // - // Once the user has typed past the anchor (rawPrefix is - // non-empty), the direction-sensitive point has been passed: - // the trailing text acts as a commit signal, and backward is - // neutralized by the content after the anchor. The loaded - // completions are still valid for trie filtering. - // - // If input is shorter than anchor, A2 (anchor diverged) will - // catch it. If input is longer but the separator isn't - // satisfied, A3 will catch it. So this check only needs to - // handle the exact-anchor case. + // [A4] DIR-SENS — backward at a direction-sensitive anchor. if ( direction === "backward" && this.directionSensitive && @@ -310,167 +314,141 @@ export class PartialCompletionSession { debug( `Partial completion re-fetch: backward at anchor, directionSensitive`, ); - return false; + return undefined; } - // [A2] RE-FETCH — input moved past the anchor (e.g. backspace, new word). - if (!input.startsWith(anchor)) { - debug( - `Partial completion re-fetch: anchor diverged (anchor='${anchor}', input='${input}')`, - ); + return anchor; + } + + private reuseSession( + input: string, + getPosition: (prefix: string) => SearchMenuPosition | undefined, + direction: CompletionDirection, + ): boolean { + // ── A. Session validity ────────────────────────────────────── + // [A1] PENDING — a fetch is already in flight, wait. + if (this.completionP !== undefined) { + debug(`Partial completion pending: ${this.anchor}`); + return true; + } + + // [A2] - [A4] + const anchor = this.getActiveAnchor(input, direction); + if (anchor === undefined) { return false; } + const { noMatchPolicy } = this; - // Separator handling: the character immediately after the anchor must - // satisfy the separatorMode constraint. - // "space": whitespace required - // "spacePunctuation": whitespace or Unicode punctuation required - // "optionalSpacePunctuation"/"optionalSpace"/"none": no separator needed, fall through to SHOW - // - // Three sub-cases when a separator IS required: - // "" — separator not typed yet: HIDE+KEEP (separator may still arrive) - // " …" — separator present: SHOW (fall through, strip it below) - // "x…" — non-separator typed right after anchor: RE-FETCH (the - // separator constraint can never be satisfied without - // backtracking, so treat this as a new input) - // - // NOTE: The anchor (derived from startIndex) may already - // include whitespace when the grammar consumed it (e.g. - // an escaped literal space like `hello\ ` in a grammar - // rule, where the space is part of the token itself). - // In that case separatorMode may still require a separator - // — this is intentional and means the grammar expects a - // *second* separator after the anchor. Do not "fix" this - // by trimming the anchor or adjusting startIndex; the - // agent is the authority on where it stopped parsing. const rawPrefix = input.substring(anchor.length); - const needsSep = requiresSeparator(sepMode); - if (needsSep) { - if (rawPrefix === "") { + const sepLevel = computeSepLevel(rawPrefix); + + // ── B. Menu anchor — is the trie at the right level? ───────── + + if (sepLevel < this.menuSepLevel) { + if (itemsAtLevel(this.partitions, sepLevel).length > 0) { + // [B1] NARROW — user backed into a lower level that has items. + this.loadLevel(sepLevel); + // Fall through to C. + } else if (rawPrefix === "") { + // [B2] BEFORE-MENU — at anchor, waiting for separator. debug( - `Partial completion deferred: still waiting for separator`, + `Partial completion deferred: sepLevel=${sepLevel} < menuSepLevel=${this.menuSepLevel}, no items`, ); this.menu.hide(); - return true; // HIDE+KEEP - } - if (!separatorRegex(sepMode).test(rawPrefix)) { - // [A3] noMatchPolicy is not consulted for "accept" vs - // "refetch" here: it describes whether the completion - // *entries* are exhaustive, not whether the anchor - // token can extend. The grammar may parse the longer - // input on a completely different path. - // - // However, when noMatchPolicy is "slide", the anchor - // sits at a sliding wildcard boundary — the user is - // still typing within the wildcard, and re-fetching - // would produce the same result at a shifted position. - // Instead, slide the anchor forward to the current - // input: the trie and metadata stay intact, so the menu - // will re-appear at the next word boundary when the - // user types a separator. - if (noMatchPolicy === "slide") { - debug( - `Partial completion anchor slide (A3): '${anchor}' → '${input}' (slide)`, - ); - this.anchor = input; - this.menu.hide(); - return true; - } + return true; + } else if (noMatchPolicy === "slide") { + // Separator expected but non-separator typed; slide anchor. + debug( + `Partial completion anchor slide: '${anchor}' → '${input}'`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } else { + // Separator expected but non-separator typed; re-fetch. debug( - `Partial completion re-fetch: non-separator after anchor (mode='${sepMode}', rawPrefix='${rawPrefix}')`, + `Partial completion re-fetch: non-separator at sepLevel=${sepLevel}, menuSepLevel=${this.menuSepLevel}`, ); - return false; // RE-FETCH (session invalidation) + return false; } } - // SHOW — strip the leading separator (if any) before passing to the - // menu trie, so completions like "music" match prefix "" not " ". - // "optionalSpace" mode: separator is not required, but when present it - // should be stripped so the trie sees clean completion text. - // "optionalSpacePunctuation" mode: same as "optionalSpace", but also - // strips leading punctuation (for [spacing=optional] grammars - // where punctuation is a valid separator). - const completionPrefix = stripLeadingSeparator(rawPrefix, sepMode); - - const position = getPosition(completionPrefix); - if (position !== undefined) { + // At the anchor with no items loaded — hide and wait. + // Covers error-recovery (anchor preserved but no data) and + // genuinely empty results at rawPrefix="". + if ( + rawPrefix === "" && + itemsAtLevel(this.partitions, this.menuSepLevel).length === 0 + ) { debug( - `Partial completion update: '${completionPrefix}' @ ${JSON.stringify(position)}`, - ); - const uniquelySatisfied = this.menu.updatePrefix( - completionPrefix, - position, + `Partial completion deferred: no items at menuSepLevel=${this.menuSepLevel}`, ); + this.menu.hide(); + return true; + } + + // ── C + D loop: trie matching with exhaustion cascade ──────── + + for (;;) { + const uniquelySatisfied = this.positionMenu(rawPrefix, getPosition); - // [B1] The user has typed text that exactly matches one - // completion and is not a prefix of any other. - // Always re-fetch for the next level — the direction - // for the re-fetch comes from the caller. + // [C1] UNIQUE — exactly one match, re-fetch for next level. if (uniquelySatisfied) { - debug( - `Partial completion re-fetch: '${completionPrefix}' uniquely satisfied`, - ); - return false; // RE-FETCH (hierarchical navigation) + debug(`Partial completion re-fetch: uniquely satisfied`); + return false; } - // [B2] Committed-past-boundary: the prefix contains whitespace - // or punctuation, meaning the user typed past a completion entry. - // If the text before the first separator exactly matches a - // completion, re-fetch for the next level. This handles the - // case where an entry (e.g. "set") is also a prefix of other - // entries ("setWindowState") so uniquelySatisfied is false, - // but the user committed by typing a separator. + // [C2] COMMITTED — separator after a valid match. + const completionPrefix = stripAtLevel(rawPrefix, this.menuSepLevel); const sepMatch = completionPrefix.match(/^(.+?)[\s\p{P}]/u); if (sepMatch !== null && this.menu.hasExactMatch(sepMatch[1])) { debug( `Partial completion re-fetch: '${sepMatch[1]}' committed with separator`, ); - return false; // RE-FETCH (hierarchical navigation) + return false; } - } else { - debug( - `Partial completion: no position for prefix '${completionPrefix}', hiding menu`, - ); - this.menu.hide(); - } - // [C1] When the menu is still active (trie has matches) we always - // reuse — the loaded completions are still useful. When there are - // NO matches, the decision depends on `noMatchPolicy`: - // "accept" → the set is exhaustive; the user typed past all - // valid continuations, so re-fetching won't help. - // "refetch" → the set is open-ended; the user may have typed - // something valid that wasn't loaded, so re-fetch - // with the longer input (open-set discovery). - // "slide" → the anchor sits at a sliding wildcard boundary; - // slide forward instead of re-fetching (wasteful, - // same result) or giving up (stuck). The trie - // stays intact so the menu will re-appear at the - // next word boundary. - const active = this.menu.isActive(); - if (!active) { - switch (noMatchPolicy) { - case "slide": - debug( - `Partial completion anchor slide (C1): '${anchor}' → '${input}' (slide)`, - ); - this.anchor = input; - this.menu.hide(); - return true; - case "accept": - debug( - `Partial completion reuse: noMatchPolicy=accept, menuActive=false`, - ); - return true; - case "refetch": - debug( - `Partial completion re-fetch: noMatchPolicy=refetch, menuActive=false`, - ); - return false; + // [C3] ACTIVE — trie has matches, menu visible. + if (this.menu.isActive()) { + debug(`Partial completion reuse: menuActive=true`); + return true; + } + + // ── D. Exhaustion cascade ──────────────────────────────── + + // [D1] WIDEN — higher level available, reload trie. + if (sepLevel > this.menuSepLevel) { + this.loadLevel((this.menuSepLevel + 1) as SepLevel); + debug( + `Partial completion widen: menuSepLevel=${this.menuSepLevel}`, + ); + continue; // loop back to C + } + + // [D2] SLIDE — wildcard boundary, slide anchor forward. + if (noMatchPolicy === "slide") { + debug( + `Partial completion anchor slide: '${anchor}' → '${input}'`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } + + // [D3] REFETCH — open-ended set, backend may know more. + if (noMatchPolicy === "refetch") { + debug( + `Partial completion re-fetch: noMatchPolicy=refetch, menu exhausted`, + ); + return false; } + + // [D4] ACCEPT — exhaustive set, nothing else to show. + debug( + `Partial completion reuse: noMatchPolicy=accept, menu exhausted`, + ); + return true; } - debug(`Partial completion reuse: menuActive=true`); - return true; } // Start a new completion session: issue backend request and process result. @@ -483,7 +461,8 @@ export class PartialCompletionSession { this.menu.hide(); this.menu.setChoices([]); this.anchor = input; - this.separatorMode = "space"; + this.partitions = []; + this.menuSepLevel = 0; this.noMatchPolicy = "refetch"; const completionP = this.dispatcher.getCommandCompletion( input, @@ -500,29 +479,19 @@ export class PartialCompletionSession { this.completionP = undefined; debug(`Partial completion result: `, result); - this.separatorMode = result.separatorMode ?? "space"; this.noMatchPolicy = computeNoMatchPolicy( result.closedSet, result.afterWildcard, ); this.directionSensitive = result.directionSensitive; - const completions = toMenuItems(result.completions); + const partitions = toPartitions(result.completions); - if (completions.length === 0) { + if (partitions.length === 0) { debug( `Partial completion skipped: No completions for '${input}'`, ); - // Keep anchor at the full input so the anchor - // covers the entire typed text. The menu stays empty, - // so reuseSession()'s SHOW path will use noMatchPolicy - // to decide: "accept" → reuse (nothing more exists); - // "refetch" → re-fetch when new input arrives. - // - // Override separatorMode: with no completions, there is - // nothing to separate from, so the separator check in - // reuseSession() should not interfere. - this.separatorMode = "none"; + this.partitions = []; return; } @@ -533,8 +502,15 @@ export class PartialCompletionSession { ? input.substring(0, result.startIndex) : input; this.anchor = partial; - - this.menu.setChoices(completions); + this.partitions = partitions; + + // Pick the best trie level for the caller's input: + // highest level ≤ inputSepLevel with items, falling + // back to lowestLevelWithItems for skip-ahead. + const rawPrefix = input.substring(partial.length); + this.loadLevel( + targetLevel(partitions, computeSepLevel(rawPrefix)), + ); // If triggered by an explicit close, only reopen when the // anchor advanced. Same anchor means the same completions at @@ -552,7 +528,7 @@ export class PartialCompletionSession { } // Re-run update with captured input to show the menu (or defer - // if the separator has not been typed yet). + // if no items are visible yet). this.reuseSession(input, getPosition, direction); }) .catch((e) => { @@ -566,57 +542,159 @@ export class PartialCompletionSession { } } -// ── Separator helpers ──────────────────────────────────────────────────────── +// ── SepLevel: separator progression model ──────────────────────────────── +// +// Three ordered levels describing the leading separator characters in +// rawPrefix (text typed after the anchor). Each level defines which +// SeparatorMode values are visible and how to strip the separator +// before passing the remainder to the trie. +// +// Level 0 (none): No separator. Items needing no separator. +// Level 1 (space): Whitespace present. Strip whitespace only. +// Level 2 (spacePunc): Whitespace + punctuation. Strip both. +// +// Visibility matrix (non-cumulative, per-level): +// +// SeparatorMode Lv0 Lv1 Lv2 +// "none" ✓ — — +// "optionalSpace" ✓ ✓ — +// "optionalSpacePunctuation" ✓ ✓ ✓ +// undefined / "space" — ✓ — +// "spacePunctuation" — ✓ ✓ +// +// Each level has its own set of items. Widening replaces the trie +// (not appends). + +type SepLevel = 0 | 1 | 2; + +function computeSepLevel(rawPrefix: string): SepLevel { + if (rawPrefix === "") return 0; + // Check the leading separator portion for whitespace and punctuation. + const leadingSep = rawPrefix.match(/^[\s\p{P}]+/u); + if (leadingSep === null) return 0; + const sep = leadingSep[0]; + if (/\p{P}/u.test(sep)) return 2; + // Only whitespace in the leading separator portion. + return 1; +} + +// Returns true when a partition's separatorMode belongs to the given +// level per the visibility matrix. Non-cumulative. +function isModeAtLevel( + mode: SeparatorMode | undefined, + level: SepLevel, +): boolean { + switch (level) { + case 0: + return ( + mode === "none" || + mode === "optionalSpace" || + mode === "optionalSpacePunctuation" + ); + case 1: + return ( + mode === undefined || + mode === "space" || + mode === "optionalSpace" || + mode === "optionalSpacePunctuation" || + mode === "spacePunctuation" + ); + case 2: + return ( + mode === "optionalSpacePunctuation" || + mode === "spacePunctuation" + ); + } +} + +// Returns items from partitions whose separatorMode belongs to the +// given level. Non-cumulative — each level has its own item set. +function itemsAtLevel( + partitions: ItemPartition[], + level: SepLevel, +): SearchMenuItem[] { + const result: SearchMenuItem[] = []; + for (const p of partitions) { + if (isModeAtLevel(p.mode, level)) { + for (const item of p.items) { + result.push(item); + } + } + } + return result; +} -function requiresSeparator(mode: SeparatorMode): boolean { - return mode === "space" || mode === "spacePunctuation"; +// Returns the lowest SepLevel that has items. Used for skip-ahead: +// when no items exist at level 0, jump to the first level that does. +function lowestLevelWithItems(partitions: ItemPartition[]): SepLevel { + for (let level: SepLevel = 0; level <= 2; level++) { + if (itemsAtLevel(partitions, level as SepLevel).length > 0) { + return level as SepLevel; + } + } + return 0; } -function separatorRegex(mode: SeparatorMode): RegExp { - return mode === "space" ? /^\s/ : /^[\s\p{P}]/u; +// Returns the highest SepLevel ≤ maxLevel that has items, falling back +// to lowestLevelWithItems when nothing exists at or below maxLevel +// (e.g. entities that only appear at level 1+). +function targetLevel( + partitions: ItemPartition[], + maxLevel: SepLevel, +): SepLevel { + for (let l = maxLevel; l > 0; l--) { + if (itemsAtLevel(partitions, l as SepLevel).length > 0) { + return l as SepLevel; + } + } + return lowestLevelWithItems(partitions); } -// Strip leading separator characters from rawPrefix. -// For "space" and "optionalSpace" modes, only whitespace is stripped. -// ("optionalSpace" uses whitespace-only because it covers CJK/digit/mixed -// contexts where only whitespace is meaningful as a separator.) -// For "spacePunctuation" and "optionalSpacePunctuation" modes, leading -// whitespace and punctuation are stripped. -function stripLeadingSeparator(rawPrefix: string, mode: SeparatorMode): string { - switch (mode) { - case "none": +// Strip leading separator characters from rawPrefix based on the level. +function stripAtLevel(rawPrefix: string, level: SepLevel): string { + switch (level) { + case 0: return rawPrefix; - case "space": - case "optionalSpace": + case 1: return rawPrefix.trimStart(); - case "spacePunctuation": - case "optionalSpacePunctuation": + case 2: return rawPrefix.replace(/^[\s\p{P}]+/u, ""); } } -// Convert backend CompletionGroups into flat SearchMenuItems, +// An items-by-mode bucket. Each partition holds the items from one or more +// CompletionGroups that share the same separatorMode. +type ItemPartition = { + mode: SeparatorMode | undefined; + items: SearchMenuItem[]; +}; + +// Convert backend CompletionGroups into partitions keyed by separatorMode, // preserving group order and sorting within each group. -function toMenuItems(groups: CompletionGroup[]): SearchMenuItem[] { - const items: SearchMenuItem[] = []; +function toPartitions(groups: CompletionGroup[]): ItemPartition[] { + const map = new Map(); let sortIndex = 0; for (const group of groups) { const sorted = group.sorted ? group.completions : [...group.completions].sort(); + const mode = group.separatorMode; + let bucket = map.get(mode); + if (bucket === undefined) { + bucket = []; + map.set(mode, bucket); + } for (const choice of sorted) { - items.push({ + bucket.push({ matchText: choice, selectedText: choice, sortIndex: sortIndex++, - ...(group.needQuotes !== undefined - ? { needQuotes: group.needQuotes } - : {}), - ...(group.emojiChar !== undefined - ? { emojiChar: group.emojiChar } - : {}), + needQuotes: group.needQuotes, + emojiChar: group.emojiChar, }); } } - return items; + return Array.from(map, ([mode, items]) => ({ mode, items })).filter( + (p) => p.items.length > 0, + ); } diff --git a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts index 0d34a19db..b96b1c9d3 100644 --- a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts +++ b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts @@ -48,12 +48,16 @@ function makeGrammarDispatcher( const startIndex = result.matchedPrefixLength ?? 0; const completions: CompletionGroup[] = []; - // Keyword completions from the grammar. - if (result.completions.length > 0) { - completions.push({ - name: "Keywords", - completions: result.completions, - }); + // Keyword completions from the grammar — each grammar group + // already carries its own separatorMode. + for (const g of result.groups) { + if (g.completions.length > 0) { + completions.push({ + name: "Keywords", + completions: g.completions, + separatorMode: g.separatorMode, + }); + } } // Entity completions: for each property the grammar identifies as @@ -76,7 +80,6 @@ function makeGrammarDispatcher( return Promise.resolve({ startIndex, completions, - separatorMode: result.separatorMode, closedSet: result.closedSet ?? true, directionSensitive: result.directionSensitive, afterWildcard: result.afterWildcard, @@ -197,6 +200,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); session.update("skip", getPos); + await flush(); // 'skip' exact-matches one entry → uniquelySatisfied → re-fetch expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); @@ -204,6 +208,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => "skip", "forward", ); + // "skip" is terminal — no further completions, menu inactive. + expect(menu.isActive()).toBe(false); }); }); @@ -229,8 +235,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // Grammar returns properties for "name" → mock entities injected. - // separatorMode is "spacePunctuation", so menu is hidden until - // space is typed (HIDE+KEEP). + // Entity group has separatorMode "space" → deferred until space typed. + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "Bohemian Rhapsody" }), @@ -238,6 +244,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect.objectContaining({ matchText: "Shape of You" }), ]), ); + // Entities deferred (separatorMode "spacePunctuation", no separator typed). + expect(menu.isActive()).toBe(false); }); test("'play' with separator deferred — menu hidden until space typed", async () => { @@ -247,6 +255,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // separatorMode="spacePunctuation" and rawPrefix="" → HIDE+KEEP + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(menu.isActive()).toBe(false); }); @@ -256,8 +265,13 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play", getPos); await flush(); + // Trie was preloaded — no redundant setChoices after space. + menu.setChoices.mockClear(); session.update("play ", getPos); + // No new fetch — trie already populated at anchor "play". + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(menu.setChoices).not.toHaveBeenCalled(); // Separator present → menu activated with entity items. expect(menu.isActive()).toBe(true); expect(menu.updatePrefix).toHaveBeenLastCalledWith( @@ -274,6 +288,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play sha", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // Trie prefix "sha" matches "Shake It Off" and "Shape of You" expect(menu.updatePrefix).toHaveBeenLastCalledWith( "sha", @@ -289,6 +305,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); session.update("play Shake It Off", getPos); + await flush(); // "Shake It Off" uniquely matches → re-fetch for next level expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); @@ -296,6 +313,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => "play Shake It Off", "forward", ); + // Next-level "by" keyword deferred (separatorMode "spacePunctuation"). + expect(menu.isActive()).toBe(false); }); }); @@ -320,12 +339,21 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // Grammar at "play Shake It Off": completions=["by"], - // separatorMode="spacePunctuation" + // separatorMode="spacePunctuation" → deferred until separator typed. + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "by" }), ]), ); + expect(menu.isActive()).toBe(false); + + // Type space → "by" becomes visible. + // Trie was already preloaded — no redundant setChoices call. + menu.setChoices.mockClear(); + session.update("play Shake It Off ", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); }); test("'by' keyword separator deferred, then shown with space", async () => { @@ -337,10 +365,14 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // separatorMode="spacePunctuation", rawPrefix="" → deferred + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); expect(menu.isActive()).toBe(false); - // Type space + // Type space — trie was preloaded, no redundant setChoices. + menu.setChoices.mockClear(); session.update("play Shake It Off ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); + expect(menu.setChoices).not.toHaveBeenCalled(); expect(menu.isActive()).toBe(true); }); @@ -356,11 +388,14 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // "by" uniquely satisfied → re-fetch → grammar returns artist properties + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(4); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play Shake It Off by", "forward", ); - // Entity values for "artist" should be loaded + // Entity values loaded but deferred (separatorMode "space", + // rawPrefix "" → not visible yet). + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "Ed Sheeran" }), @@ -368,6 +403,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect.objectContaining({ matchText: "Taylor Swift" }), ]), ); + // Entities deferred (separatorMode "spacePunctuation"). + expect(menu.isActive()).toBe(false); }); test("artist entities narrowed by trie after typing prefix", async () => { @@ -381,6 +418,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); session.update("play Shake It Off by T", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(4); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "T", expect.anything(), @@ -410,6 +449,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("", getPos); await flush(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); expect(menu.setChoices).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "set" }), @@ -426,13 +466,17 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // Grammar returns completions: ["volume", "brightness"] + // with separatorMode "spacePunctuation" → deferred. expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "brightness" }), expect.objectContaining({ matchText: "volume" }), ]), ); + // Properties deferred (separatorMode "spacePunctuation"). + expect(menu.isActive()).toBe(false); }); test("'set v' narrows property alternatives via trie", async () => { @@ -443,6 +487,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("set v", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // Trie prefix "v" → only "volume" matches expect(menu.updatePrefix).toHaveBeenLastCalledWith( "v", @@ -463,6 +509,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // "volume" uniquely satisfied → re-fetch → grammar returns // properties for "value" → mock entities injected expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); + // Entity group has separatorMode "space" → deferred. + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "high" }), @@ -470,6 +518,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect.objectContaining({ matchText: "medium" }), ]), ); + // Entities deferred (separatorMode "spacePunctuation"). + expect(menu.isActive()).toBe(false); }); test("'set volume ' (with space) shows entity values in menu", async () => { @@ -480,8 +530,13 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("set volume", getPos); await flush(); + // Trie was preloaded — no redundant setChoices after space. + menu.setChoices.mockClear(); session.update("set volume ", getPos); + // No new fetch — trie already populated at anchor "set volume". + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); + expect(menu.setChoices).not.toHaveBeenCalled(); expect(menu.isActive()).toBe(true); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "", @@ -499,6 +554,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("set volume m", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "m", expect.anything(), @@ -545,14 +602,26 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore, ); + // Sliding hides the menu (no separator typed). + expect(menu.isActive()).toBe(false); }); test("'by' keyword appears after wildcard text with space", async () => { await primeWildcard(); + const fetchCountAfterPrime = + dispatcher.getCommandCompletion.mock.calls.length; + + // Trie was preloaded — no redundant setChoices after space. + menu.setChoices.mockClear(); // After typing space, the separator is satisfied and trie filters. session.update("play unknown ", getPos); + // No new fetch — trie already populated at anchor. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountAfterPrime, + ); + expect(menu.setChoices).not.toHaveBeenCalled(); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "", expect.anything(), @@ -577,12 +646,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect.anything(), ); // The trie should show "by" as a narrowed match. - expect(menu.setChoices).toHaveBeenCalled(); - const lastChoices = - menu.setChoices.mock.calls[ - menu.setChoices.mock.calls.length - 1 - ][0]; - expect(lastChoices).toEqual( + expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "by" }), ]), @@ -614,8 +678,15 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => test("double space 'play unknown ' shows keyword menu", async () => { await primeWildcard(); + const fetchCountAfterPrime = + dispatcher.getCommandCompletion.mock.calls.length; + session.update("play unknown ", getPos); + // No new fetch — extra space does not trigger re-fetch. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountAfterPrime, + ); // Extra space should not break the trie display. expect(menu.isActive()).toBe(true); }); @@ -672,6 +743,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => "forward", ); // Grammar returns artist properties → mock entities injected. + // Entity group has separatorMode "space" → deferred at anchor. + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "Ed Sheeran" }), @@ -679,6 +752,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect.objectContaining({ matchText: "Taylor Swift" }), ]), ); + // Entities deferred (separatorMode "spacePunctuation"). + expect(menu.isActive()).toBe(false); }); }); @@ -713,21 +788,32 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session = new PartialCompletionSession(menu, dispatcher); }); - test("'play beautiful' has afterWildcard=\"none\" — mixed candidates", async () => { + test("'play beautiful' has afterWildcard=\"some\" — mixed candidates deferred", async () => { session.update("play beautiful", getPos); await flush(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); // Both rules contribute at mpl=14: // "music" from Rule B (literal, position-sensitive) // "by" from Rule A (wildcard-stable) - // AND-merge → afterWildcard="none" → noMatchPolicy="refetch" - const choices = menu.setChoices.mock.calls.at(-1)![0]; - expect(choices).toEqual( + // afterWildcard="some" → noMatchPolicy="refetch" + // separatorMode="spacePunctuation" → items deferred at anchor. + // Trie is preloaded with all items (no prior groups visible). + expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "music" }), expect.objectContaining({ matchText: "by" }), ]), ); + expect(menu.isActive()).toBe(false); + + // Type space → items become visible. + // Trie was already preloaded — no redundant setChoices call. + menu.setChoices.mockClear(); + session.update("play beautiful ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); }); test("typing into wildcard triggers re-fetch, not slide", async () => { @@ -752,24 +838,34 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play beautiful", getPos); await flush(); - // Type more — triggers re-fetch since afterWildcard="none". + // Type more — triggers re-fetch since afterWildcard="some". session.update("play beautifull", getPos); await flush(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // At "play beautifull", only the wildcard rule contributes. // "music" should be gone — it was position-sensitive to // "play beautiful" (exact partial match of Rule B). - const choices = menu.setChoices.mock.calls.at(-1)![0]; - expect(choices).toEqual( + // Trie is preloaded with only "by" (no "music"). + expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "by" }), ]), ); - expect(choices).not.toEqual( + expect(menu.setChoices).not.toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "music" }), ]), ); + expect(menu.isActive()).toBe(false); + + // Type space → items become visible. + // Trie was already preloaded — no redundant setChoices call. + menu.setChoices.mockClear(); + session.update("play beautifull ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); }); test("space then non-matching prefix triggers re-fetch (afterWildcard some)", async () => { @@ -797,15 +893,19 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // Space → menu shows "music" and "by". session.update("play beautiful ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); expect(menu.isActive()).toBe(true); - // "b" matches "by" in the trie — menu narrows correctly. + // "b" matches "by" in the trie — no new fetch. session.update("play beautiful b", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); expect(menu.isActive()).toBe(true); // Verify the trie filtered to only "by". - const updateArgs = menu.updatePrefix.mock.calls.at(-1)!; - expect(updateArgs[0]).toBe("b"); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "b", + expect.anything(), + ); }); test('single-rule wildcard still slides (afterWildcard="all")', async () => { @@ -822,9 +922,14 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountAfterPrime, ); + // Sliding hides the menu (no separator typed). + expect(menu.isActive()).toBe(false); // After separator, "by" reappears from the cached trie. session.update("play hello world ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountAfterPrime, + ); expect(menu.isActive()).toBe(true); }); }); @@ -850,6 +955,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => const fetchCountBefore = dispatcher.getCommandCompletion.mock.calls.length; session.update("play", getPos, "backward"); + await flush(); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore + 1, @@ -858,6 +964,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => "play", "backward", ); + // Entities deferred after backward re-fetch. + expect(menu.isActive()).toBe(false); }); test("backward past anchor (typing beyond) does not re-fetch", async () => { @@ -885,6 +993,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore, ); + // Menu still active — separator present, entity items visible. + expect(menu.isActive()).toBe(true); }); }); @@ -941,6 +1051,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // Re-type space — menu should reappear without re-fetch. session.update("play ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); expect(menu.isActive()).toBe(true); }); @@ -968,6 +1079,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play ", getPos); + // No new fetch — double space does not trigger re-fetch. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // The extra space should not leak into the trie filter. // Menu should be active with "music" offered. expect(menu.isActive()).toBe(true); @@ -1019,6 +1132,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play ", getPos); + // No new fetch — double space does not trigger re-fetch. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); // Double space should not break entity display. expect(menu.isActive()).toBe(true); expect(menu.setChoices).toHaveBeenLastCalledWith( @@ -1041,6 +1156,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play xyz", getPos); await flush(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play xyz", "forward", @@ -1130,49 +1246,51 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => expect(menu.isActive()).toBe(true); }); - test("'ab ' trailing sep: P advances by 1, closedSet=false", async () => { + test("'ab ' trailing sep: both groups visible, no re-fetch (closedSet=true)", async () => { session.update("ab", getPos); await flush(); - // Type space — anchor diverges from "ab" to "ab " because - // closedSet=false means noMatchPolicy="refetch". The space - // doesn't match "cd" in the trie → re-fetch. + // "none" group is immediately visible — no preload. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.isActive()).toBe(true); + + // Type space — in the per-group model, both groups are + // preserved (closedSet=true). The "none" mode "cd" was already + // visible; now the "spacePunctuation" mode "cd" also becomes + // visible. Both groups are loaded at the same anchor — no re-fetch. + menu.setChoices.mockClear(); session.update("ab ", getPos); - await flush(); - // Re-fetch at "ab " returns: completions=["cd"], - // matchedPrefixLength=3 (P advanced past one separator), - // separatorMode="optionalSpace". - expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); - expect(menu.setChoices).toHaveBeenLastCalledWith( + // No new fetch — both groups already loaded at anchor "ab". + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + // setChoices IS called again (visibility changed — more items now visible). + expect(menu.setChoices).toHaveBeenCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "cd" }), ]), ); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); expect(menu.isActive()).toBe(true); }); - test("backspace from 'ab ' to 'ab': anchor diverges → re-fetch", async () => { + test("backspace from 'ab ' to 'ab': reuses session (anchor unchanged)", async () => { session.update("ab", getPos); await flush(); session.update("ab ", getPos); - await flush(); - const fetchCountBefore = - dispatcher.getCommandCompletion.mock.calls.length; - - // Backspace removes the space — anchor was "ab " (P=3), - // input "ab" doesn't start with "ab " → anchor diverged → re-fetch. + // In the per-group model, no re-fetch happened at "ab " + // (both groups preserved, closedSet=true → noMatchPolicy=accept). + // Anchor is still "ab". Backspace to "ab" is at the anchor. session.update("ab", getPos); - await flush(); - expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( - fetchCountBefore + 1, - ); - expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( - "ab", - "forward", - ); + // No re-fetch — same anchor, same session. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + // "none" mode "cd" is still visible; "spacePunctuation" mode + // "cd" is hidden (separator removed). Menu shows "cd". + expect(menu.isActive()).toBe(true); }); test("double space 'ab ': optional mode strips extra space, menu stays visible", async () => { @@ -1188,6 +1306,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("ab ", getPos); await flush(); + // No new fetch — both groups already loaded at anchor "ab". + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "", expect.anything(), @@ -1204,6 +1324,8 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // → trie prefix "c" narrows to "cd". session.update("abc", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "c", expect.anything(), @@ -1221,11 +1343,263 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => // rawPrefix="c" → trie prefix "c" → matches "cd". session.update("ab c", getPos); + // No new fetch — trie handles narrowing. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "c", + expect.anything(), + ); + expect(menu.isActive()).toBe(true); + }); + }); + + // ── SepLevel transitions with real grammar output ──────────────── + + describe("SepLevel transitions with conflict grammar", () => { + // The conflict grammar has two rules for "ab cd": + // NoneRule [spacing=none]: separatorMode="none" → level 0 only + // AutoRule: separatorMode="space" → level 1 only + // This produces two groups at different SepLevels. + + let menu: TestSearchMenu; + let dispatcher: ReturnType; + let session: PartialCompletionSession; + + const conflictGrammar2 = loadGrammarRules( + "conflict2.grammar", + [ + ` [spacing=none] = ab cd -> "none";`, + ` = ab cd -> "auto";`, + ` = $(x:) -> x | $(x:) -> x;`, + ].join("\n"), + ); + + beforeEach(async () => { + menu = makeMenu(); + dispatcher = makeGrammarDispatcher(conflictGrammar2, {}); + session = new PartialCompletionSession(menu, dispatcher); + }); + + test("widen: 'ab' at level 0, type space widens to level 1", async () => { + session.update("ab", getPos); + await flush(); + + // "ab" → none-mode "cd" at level 0. Menu active. + expect(menu.isActive()).toBe(true); + + // Type space: rawPrefix=" ", sepLevel=1, menuSepLevel=0. + // " " doesn't match "cd" at level 0 → C3 fails. + // D1: sepLevel(1) > menuSepLevel(0) → widen to 1. + // Level 1: space-mode "cd". Trie reloaded. + menu.setChoices.mockClear(); + session.update("ab ", getPos); + // Exactly one setChoices for the widen reload. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("narrow: 'ab ' back to 'ab' reloads level-0 trie", async () => { + session.update("ab", getPos); + await flush(); + + // Widen to level 1. + session.update("ab ", getPos); + expect(menu.isActive()).toBe(true); + + // Backspace to "ab": sepLevel=0 < menuSepLevel=1. + // B1: items at level 0 (none-mode "cd") → NARROW. + menu.setChoices.mockClear(); + session.update("ab", getPos); + // Exactly one setChoices for the narrow reload. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.isActive()).toBe(true); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("narrow/widen round-trip: level transitions are idempotent", async () => { + session.update("ab", getPos); + await flush(); + + // Cycle: 0 → 1 → 0 → 1 + session.update("ab ", getPos); // widen to 1 + session.update("ab", getPos); // narrow to 0 + session.update("ab ", getPos); // widen to 1 again + + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + // No re-fetches through the entire round-trip. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("trie narrows correctly after widen", async () => { + session.update("ab", getPos); + await flush(); + + // Widen to level 1. + session.update("ab ", getPos); + expect(menu.isActive()).toBe(true); + + // Type "c": trie prefix "c" narrows to "cd". + // No setChoices — trie already loaded at level 1. + menu.setChoices.mockClear(); + session.update("ab c", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "c", + expect.anything(), + ); + expect(menu.setChoices).not.toHaveBeenCalled(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("trie narrows at level 0 without separator", async () => { + session.update("ab", getPos); + await flush(); + + // At level 0, none-mode "cd" visible. + // Type "c" without separator: trie prefix "c" → "cd". + // No setChoices — trie already loaded at level 0. + menu.setChoices.mockClear(); + session.update("abc", getPos); + expect(menu.isActive()).toBe(true); expect(menu.updatePrefix).toHaveBeenLastCalledWith( "c", expect.anything(), ); + expect(menu.setChoices).not.toHaveBeenCalled(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("initial fetch with separator: 'ab ' widens on first reuseSession", async () => { + // Start with the space already typed — the session's first + // reuseSession (called at the end of startNewSession) must + // widen from the lowestLevelWithItems (level 0) to level 1. + session.update("ab ", getPos); + await flush(); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + // startNewSession: lowestLevelWithItems → 0 (none-mode "cd"), + // but inputSepLevel=1 → skip ahead to level 1 (space-mode "cd"). + // loadLevel loads level-1 items directly. reuseSession runs + // with rawPrefix=" ", sepLevel=1 = menuSepLevel(1) → C succeeds. + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + // startNewSession skips the intermediate lv0 load and + // jumps directly to lv1 (the target level for sepLevel=1). + // Two setChoices: initial clear + loadLevel at lv1. + expect(menu.setChoices).toHaveBeenCalledTimes(2); + + // Narrow back to "ab": level 0 reloaded. + menu.setChoices.mockClear(); + session.update("ab", getPos); + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.isActive()).toBe(true); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + }); + + describe("SepLevel transitions with music grammar", () => { + let menu: TestSearchMenu; + let dispatcher: ReturnType; + let session: PartialCompletionSession; + + beforeEach(async () => { + menu = makeMenu(); + dispatcher = makeGrammarDispatcher(musicGrammar, musicEntities); + session = new PartialCompletionSession(menu, dispatcher); + }); + + test("entity level: skip-ahead to level 1, B2 hides at anchor", async () => { + // Navigate to entity level. + session.update("", getPos); + await flush(); + session.update("play", getPos); + await flush(); + + // After 'play' uniquely satisfies, re-fetch returns entities + // with separatorMode="spacePunctuation" → level 1+. + // lowestLevelWithItems → 1. menuSepLevel=1. + // rawPrefix="" → sepLevel=0 < menuSepLevel=1 → B2, hidden. + expect(menu.isActive()).toBe(false); + + // Type space → sepLevel=1 = menuSepLevel → C: entities shown. + // No setChoices — trie already loaded at level 1 by startNewSession. + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); + }); + + test("entity level: punctuation triggers re-fetch (entities are default/space mode)", async () => { + session.update("", getPos); + await flush(); + session.update("play", getPos); + await flush(); + + const fetchCountBefore = + dispatcher.getCommandCompletion.mock.calls.length; + + // Entity group has separatorMode=undefined → level 1 only. + // Type punctuation: sepLevel=2 > menuSepLevel=1. + // Level 1 trie has entities, prefix=trimStart(".")="." → no match. + // D1: try widen to 2, but no items at level 2 (undefined ≡ space). + // D3: noMatchPolicy="refetch" (closedSet=false) → re-fetch. + session.update("play.", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountBefore + 1, + ); + }); + + test("keyword level: backspace from entity to keyword hides menu", async () => { + session.update("", getPos); + await flush(); + session.update("play", getPos); + await flush(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); // entities shown + + // Backspace to "play" — separator removed. + // rawPrefix="" → sepLevel=0 < menuSepLevel(1). + // B2: no items at level 0 (entities are spacePunctuation) → hide. + session.update("play", getPos); + expect(menu.isActive()).toBe(false); + + // Re-type space: menu reappears. + // No setChoices — trie stays loaded at level 1 through B2. + menu.setChoices.mockClear(); + session.update("play ", getPos); expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); + }); + + test("getCompletionPrefix tracks menuSepLevel correctly", async () => { + session.update("", getPos); + await flush(); + session.update("play", getPos); + await flush(); + + // At anchor "play", menuSepLevel=1 (entities are default/space mode). + // No separator typed: sepLevel(0) < menuSepLevel(1) → undefined. + expect(session.getCompletionPrefix("play")).toBeUndefined(); + + // Space typed: sepLevel(1) >= menuSepLevel(1) → stripped prefix. + expect(session.getCompletionPrefix("play ")).toBe(""); + expect(session.getCompletionPrefix("play sha")).toBe("sha"); + + // Punctuation: sepLevel(2) >= menuSepLevel(1) → stripped at level 1. + // stripAtLevel(".sha", 1) = trimStart(".sha") = ".sha" (punct preserved). + expect(session.getCompletionPrefix("play.sha")).toBe(".sha"); }); }); @@ -1285,10 +1659,10 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => }); }); - // ── explicitHide() — explicit close and conditional refetch ────────── + // ── explicitHide() — level shift, hide, or refetch ─────────────────── - describe("explicitHide() — explicit close with conditional refetch", () => { - test("no refetch when noMatchPolicy=accept (keyword level, closed set)", async () => { + describe("explicitHide() — level shift, hide, or refetch", () => { + test("same level + input past anchor, accept policy → hide (no refetch)", async () => { const menu = makeMenu(); const dispatcher = makeGrammarDispatcher( musicGrammar, @@ -1296,7 +1670,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => ); const session = new PartialCompletionSession(menu, dispatcher); - // Establish keyword completions at anchor=""; closedSet=true → accept. + // Establish keyword completions at anchor=""; closedSet=true. session.update("", getPos); await flush(); @@ -1307,13 +1681,19 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => const fetchCountBefore = dispatcher.getCommandCompletion.mock.calls.length; - // Explicit close: input "p" ≠ anchor "" but noMatchPolicy=accept → skip refetch. + // Escape at "p": same sepLevel(0), input "p" ≠ anchor "" but + // noMatchPolicy="accept" (closedSet=true, afterWildcard="none") + // → refetch can't help, just hide. session.explicitHide("p", getPos, "forward"); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore, ); - expect(menu.hide).toHaveBeenCalled(); + expect(menu.isActive()).toBe(false); + + // Session data preserved — typing more still works via reuseSession. + session.update("pa", getPos); + expect(menu.isActive()).toBe(true); }); test("no refetch when input equals anchor", async () => { @@ -1331,15 +1711,16 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => const fetchCountBefore = dispatcher.getCommandCompletion.mock.calls.length; - // input === anchor → skip refetch. + // input === anchor → no level shift, no advance → just hide. session.explicitHide("", getPos, "forward"); expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore, ); + expect(menu.isActive()).toBe(false); }); - test("refetch triggered; same anchor → reopen suppressed", async () => { + test("same entity level + input past anchor → refetch, suppress reopen", async () => { const menu = makeMenu(); const dispatcher = makeGrammarDispatcher( musicGrammar, @@ -1353,27 +1734,95 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play", getPos); await flush(); session.update("play ", getPos); // separator → menu active - session.update("play sha", getPos); // trie filters to Shake/Shape expect(menu.isActive()).toBe(true); const fetchCountBefore = dispatcher.getCommandCompletion.mock.calls.length; - // Escape while menu shows entity completions for prefix "sha". - // Grammar resolves "play sha" to startIndex=4 → anchor "play" unchanged. - session.explicitHide("play sha", getPos, "forward"); + // Escape at "play ": same sepLevel(1), input ≠ anchor → refetch. + // Backend returns startIndex=4 → anchor "play" unchanged → + // explicitCloseAnchor matches → suppress reopen. + session.explicitHide("play ", getPos, "forward"); await flush(); - // Refetch was issued with the full current input. expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( fetchCountBefore + 1, ); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( - "play sha", + "play ", "forward", ); - // Same anchor → reopen suppressed: menu stays hidden. expect(menu.isActive()).toBe(false); + + // Session data refreshed — typing more narrows without another refetch. + session.update("play sha", getPos); + expect(menu.isActive()).toBe(true); + }); + + test("level change on explicitHide keeps menu visible (widen)", async () => { + // Conflict grammar: NoneRule at level 0, AutoRule at level 1. + const grammar = loadGrammarRules( + "explicithide-conflict.grammar", + [ + ` [spacing=none] = ab cd -> "none";`, + ` = ab cd -> "auto";`, + ` = $(x:) -> x | $(x:) -> x;`, + ].join("\n"), + ); + const menu = makeMenu(); + const dispatcher = makeGrammarDispatcher(grammar, {}); + const session = new PartialCompletionSession(menu, dispatcher); + + // "ab" → level 0 (none-mode "cd"). Menu active. + session.update("ab", getPos); + await flush(); + expect(menu.isActive()).toBe(true); + + const fetchCountBefore = + dispatcher.getCommandCompletion.mock.calls.length; + + // explicitHide with "ab " → level shift widens to level 1. + // Level changed → menu stays visible with level-1 items. + session.explicitHide("ab ", getPos, "forward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountBefore, + ); + // Menu stays visible — new items the user hasn't seen. + expect(menu.isActive()).toBe(true); + }); + + test("level change on explicitHide keeps menu visible (narrow)", async () => { + const grammar = loadGrammarRules( + "explicithide-conflict2.grammar", + [ + ` [spacing=none] = ab cd -> "none";`, + ` = ab cd -> "auto";`, + ` = $(x:) -> x | $(x:) -> x;`, + ].join("\n"), + ); + const menu = makeMenu(); + const dispatcher = makeGrammarDispatcher(grammar, {}); + const session = new PartialCompletionSession(menu, dispatcher); + + // "ab" at level 0, then widen to level 1. + session.update("ab", getPos); + await flush(); + session.update("ab ", getPos); + expect(menu.isActive()).toBe(true); + + const fetchCountBefore = + dispatcher.getCommandCompletion.mock.calls.length; + + // explicitHide with "ab" → level shift narrows to level 0. + // Level changed → menu stays visible with level-0 items. + session.explicitHide("ab", getPos, "forward"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes( + fetchCountBefore, + ); + // Menu stays visible — different level items. + expect(menu.isActive()).toBe(true); }); test("refetch triggered; anchor advances → session moves to next level", async () => { @@ -1392,18 +1841,22 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("play ", getPos); // separator → menu active // Explicit close with the full entity already typed. - // Grammar consumes "play Shake It Off" entirely → startIndex advances - // past 4 → new anchor differs from "play" → reopen is NOT suppressed. + // No level shift: same sepLevel(1). + // input ≠ anchor → refetch. + // Grammar advances startIndex past 4 → new anchor → reopen. session.explicitHide("play Shake It Off", getPos, "forward"); await flush(); // Refetch was issued with the full entity string. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(3); expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith( "play Shake It Off", "forward", ); - // Next-level completions include the "by" keyword. + // Next-level completions loaded but deferred + // (separatorMode "spacePunctuation", rawPrefix "" → not visible). + // Trie is preloaded with all items (no prior groups visible). expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ matchText: "by" }), @@ -1411,8 +1864,11 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => ); // Typing the separator at the new anchor reveals the "by" completion. + // Trie was already preloaded — no redundant setChoices call. + menu.setChoices.mockClear(); session.update("play Shake It Off ", getPos); expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); }); }); }); diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts index 702c477d1..cd532b6a5 100644 --- a/ts/packages/shell/test/partialCompletion/helpers.ts +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -8,7 +8,7 @@ import { PartialCompletionSession, } from "../../src/renderer/src/partialCompletionSession.js"; import { SearchMenuPosition } from "../../src/preload/electronTypes.js"; -import { CompletionGroup } from "@typeagent/agent-sdk"; +import { CompletionGroup, SeparatorMode } from "@typeagent/agent-sdk"; import { CommandCompletionResult } from "agent-dispatcher"; import { SearchMenuBase } from "../../src/renderer/src/searchMenuBase.js"; @@ -62,7 +62,6 @@ export function makeDispatcher( result: CommandCompletionResult = { startIndex: 0, completions: [], - separatorMode: undefined, closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -81,15 +80,44 @@ export const getPos = (_prefix: string) => anyPosition; export function makeCompletionResult( completions: string[], startIndex: number = 0, - opts: Partial = {}, + opts: Partial & { + separatorMode?: SeparatorMode; + } = {}, ): CommandCompletionResult { - const group: CompletionGroup = { name: "test", completions }; + const { separatorMode = "space", ...rest } = opts; + const group: CompletionGroup = { + name: "test", + completions, + separatorMode, + }; return { startIndex, completions: [group], closedSet: false, directionSensitive: false, afterWildcard: "none", + ...rest, + }; +} + +// Build a CommandCompletionResult with multiple CompletionGroups, +// each with its own separatorMode. +export function makeMultiGroupResult( + groups: { completions: string[]; separatorMode?: SeparatorMode }[], + startIndex: number = 0, + opts: Partial = {}, +): CommandCompletionResult { + const completions: CompletionGroup[] = groups.map((g, i) => ({ + name: `group-${i}`, + completions: g.completions, + separatorMode: g.separatorMode, + })); + return { + startIndex, + completions, + closedSet: true, + directionSensitive: false, + afterWildcard: "none", ...opts, }; } diff --git a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts index 0108cf02f..b8b75a4bd 100644 --- a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts +++ b/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts @@ -189,20 +189,27 @@ describe("PartialCompletionSession — @command routing", () => { const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); - // User types "@config" → completions loaded, menu deferred (no separator yet) + // User types "@config" → completions loaded, deferred (no separator yet) session.update("@config", getPos); await Promise.resolve(); - expect(menu.setChoices).toHaveBeenCalledWith( + // No items are immediately visible (all require a space separator), + // so the trie is preloaded with all items but the menu stays hidden. + expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ selectedText: "clear" }), ]), ); + expect(menu.isActive()).toBe(false); expect(menu.updatePrefix).not.toHaveBeenCalled(); - // User types space → separator present, menu appears + // User types space → separator present, items become visible. + // The trie was already preloaded so no redundant setChoices call. + menu.setChoices.mockClear(); session.update("@config ", getPos); + expect(menu.setChoices).not.toHaveBeenCalled(); + expect(menu.isActive()).toBe(true); expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); // No re-fetch — same session handles both states expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts index 5bb372b02..5b5d636fe 100644 --- a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts +++ b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts @@ -102,6 +102,7 @@ describe("PartialCompletionSession — result processing", () => { name: "test", completions: ["zebra", "apple", "mango"], sorted: false, + separatorMode: "none", }; const result: CommandCompletionResult = { startIndex: 0, @@ -109,7 +110,6 @@ describe("PartialCompletionSession — result processing", () => { closedSet: false, directionSensitive: false, afterWildcard: "none", - separatorMode: "none", }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -150,6 +150,7 @@ describe("PartialCompletionSession — result processing", () => { completions: ["player", "calendar"], emojiChar: "\uD83C\uDFB5", sorted: true, + separatorMode: "none", }; const result: CommandCompletionResult = { startIndex: 0, @@ -157,7 +158,6 @@ describe("PartialCompletionSession — result processing", () => { closedSet: false, directionSensitive: false, afterWildcard: "none", - separatorMode: "none", }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -185,6 +185,7 @@ describe("PartialCompletionSession — result processing", () => { name: "plain", completions: ["alpha"], sorted: true, + separatorMode: "none", }; const result: CommandCompletionResult = { startIndex: 0, @@ -192,7 +193,6 @@ describe("PartialCompletionSession — result processing", () => { closedSet: false, directionSensitive: false, afterWildcard: "none", - separatorMode: "none", }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -202,7 +202,7 @@ describe("PartialCompletionSession — result processing", () => { const calls = menu.setChoices.mock.calls; const items = calls[calls.length - 1][0] as Record[]; - expect(items[0]).not.toHaveProperty("emojiChar"); + expect(items[0].emojiChar).toBeUndefined(); }); test("sorted group preserves order while unsorted group is alphabetized", async () => { @@ -211,11 +211,13 @@ describe("PartialCompletionSession — result processing", () => { name: "grammar", completions: ["zebra", "apple"], sorted: true, + separatorMode: "none", }; const unsortedGroup: CompletionGroup = { name: "entities", completions: ["cherry", "banana"], sorted: false, + separatorMode: "none", }; const result: CommandCompletionResult = { startIndex: 0, @@ -223,7 +225,6 @@ describe("PartialCompletionSession — result processing", () => { closedSet: false, directionSensitive: false, afterWildcard: "none", - separatorMode: "none", }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts index c469b9ab1..3985ab4fc 100644 --- a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -8,6 +8,7 @@ import { makeCompletionResult, getPos, anyPosition, + makeMultiGroupResult, } from "./helpers.js"; // ── separatorMode: "space" ──────────────────────────────────────────────────── @@ -21,17 +22,18 @@ describe("PartialCompletionSession — separatorMode: space", () => { const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); - // Input without trailing space: "play" — choices are loaded but menu is not shown + // Input without trailing space: "play" — no visible items (need space) session.update("play", getPos); await Promise.resolve(); - // setChoices IS called with actual items (trie is populated for later) - expect(menu.setChoices).toHaveBeenCalledWith( + // Trie is preloaded with all items but the menu stays hidden. + expect(menu.setChoices).toHaveBeenLastCalledWith( expect.arrayContaining([ expect.objectContaining({ selectedText: "music" }), ]), ); - // But updatePrefix is NOT called yet (menu not shown) + expect(menu.isActive()).toBe(false); + // updatePrefix is NOT called (menu not shown) expect(menu.updatePrefix).not.toHaveBeenCalled(); }); @@ -57,9 +59,7 @@ describe("PartialCompletionSession — separatorMode: space", () => { test("menu shown after trailing space is typed", async () => { const menu = makeMenu(); - const result = makeCompletionResult(["music"], 4, { - separatorMode: undefined, - }); + const result = makeCompletionResult(["music"], 4); const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -430,3 +430,323 @@ describe("PartialCompletionSession — separatorMode edge cases", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); }); + +// ── SepLevel: widen / narrow / skip-ahead ────────────────────────────────── + +describe("PartialCompletionSession — SepLevel transitions", () => { + // Two groups at different levels: + // level 0: "optionalSpace" group → ["alpha"] + // level 1: "space" group → ["beta"] + // (Both visible at level 1, only optionalSpace at level 0.) + function makeTwoLevelResult() { + return makeMultiGroupResult( + [ + { completions: ["alpha"], separatorMode: "optionalSpace" }, + { completions: ["beta"], separatorMode: "space" }, + ], + 4, // anchor = "play" + ); + } + + test("D1 WIDEN: space group exhausted, punctuation widens to level 2", async () => { + const menu = makeMenu(); + // Level 1 has both (space and spacePunctuation visible at lv1). + // Level 2 has only "beta" (spacePunctuation). + const result = makeMultiGroupResult( + [ + { completions: ["alpha"], separatorMode: "space" }, + { completions: ["beta"], separatorMode: "spacePunctuation" }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // menuSepLevel = 1 via lowestLevelWithItems. + // rawPrefix="" → sepLevel=0, B2 BEFORE-MENU (hide). + expect(menu.isActive()).toBe(false); + + // Type space: sepLevel=1, menuSepLevel=1. Trie has alpha+beta. + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + + // No extra setChoices between B2→show — the existing trie at level 1 + // already had the right items loaded by startNewSession. + // (startNewSession calls loadLevel once; the space update just + // runs updatePrefix on the already-loaded trie.) + + // Type "play.": anchor "play", rawPrefix=".", sepLevel=2. + // Trie is at level 1 (alpha+beta). prefix at level 1 = trimStart(".") = ".". + // "." doesn't match "alpha" or "beta". Menu not active (C3 fails). + // D1: sepLevel(2) > menuSepLevel(1) → widen to level 2. + // Level 2: only "beta" (spacePunctuation). Trie reloaded. + // Stripped prefix at level 2: strip "." → "". Matches "beta". + menu.setChoices.mockClear(); + session.update("play.", getPos); + // Exactly one setChoices for the widen reload. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ matchText: "beta" }), + ]), + ); + expect(menu.isActive()).toBe(true); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("B1 NARROW: backspace from level 1 to level 0 reloads trie", async () => { + const menu = makeMenu(); + const result = makeTwoLevelResult(); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // menuSepLevel=0, trie has "alpha" (optionalSpace at level 0). + expect(menu.isActive()).toBe(true); + + // Type space: sepLevel=1, menuSepLevel=0. + // D1: sepLevel(1) > menuSepLevel(0) → widen to level 1. + // Level 1: "alpha" + "beta". Trie reloaded, prefix="" → shows both. + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + + // Backspace to "play": rawPrefix="", sepLevel=0. + // B1: sepLevel(0) < menuSepLevel(1) + items at level 0 → NARROW. + // Trie reloaded with level-0 items ("alpha" only). + menu.setChoices.mockClear(); + session.update("play", getPos); + // Exactly one setChoices for the narrow reload. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ matchText: "alpha" }), + ]), + ); + // Only "alpha" — "beta" (space mode) not visible at level 0. + expect(menu.setChoices).not.toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ matchText: "beta" }), + ]), + ); + expect(menu.isActive()).toBe(true); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("B2 BEFORE-MENU: skip-ahead hides menu until separator typed", async () => { + const menu = makeMenu(); + // Only "space" mode items — nothing at level 0. + const result = makeMultiGroupResult( + [{ completions: ["music"], separatorMode: "space" }], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // lowestLevelWithItems → 1. menuSepLevel=1. + // rawPrefix="" → sepLevel=0 < menuSepLevel=1 → B2 BEFORE-MENU. + expect(menu.isActive()).toBe(false); + + // Same input again — still B2. + session.update("play", getPos); + expect(menu.isActive()).toBe(false); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Type space — separator typed, sepLevel matches menuSepLevel. + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("no trie reload when sepLevel stays at same menuSepLevel", async () => { + const menu = makeMenu(); + const result = makeTwoLevelResult(); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // menuSepLevel=0. Trie loaded once with level-0 items. + // Type more characters that don't change sepLevel: + menu.setChoices.mockClear(); + session.update("playa", getPos); + session.update("playalp", getPos); + + // setChoices NOT called — trie stays loaded at level 0. + expect(menu.setChoices).not.toHaveBeenCalled(); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("widen then narrow round-trip preserves correct items", async () => { + const menu = makeMenu(); + const result = makeTwoLevelResult(); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Level 0: "alpha". Level 1: "alpha"+"beta". + expect(menu.isActive()).toBe(true); // "alpha" at level 0 + + // Widen: type space → level 1. + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + + // Narrow: backspace → level 0. + session.update("play", getPos); + expect(menu.isActive()).toBe(true); + + // Widen again: type space → level 1. + menu.setChoices.mockClear(); + session.update("play ", getPos); + // Exactly one setChoices for the widen reload. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ matchText: "alpha" }), + expect.objectContaining({ matchText: "beta" }), + ]), + ); + expect(menu.isActive()).toBe(true); + + // All within one session — no re-fetch. + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("optionalSpacePunctuation visible at all three levels", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["gamma"], + separatorMode: "optionalSpacePunctuation", + }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Level 0: "gamma" visible (optionalSpacePunctuation at lv0). + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + + // Level 1 (space): still visible. optionalSpacePunctuation is in + // both level 0 and 1 — widen reloads the trie with level-1 items, + // but it's the same single item. + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + // Widen from 0→1 reloads trie (1 setChoices call). + expect(menu.setChoices).toHaveBeenCalledTimes(1); + + // Level 2 (punctuation): still visible. Widen from 1→2. + menu.setChoices.mockClear(); + session.update("play.", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + // Widen from 1→2 reloads trie (1 setChoices call). + expect(menu.setChoices).toHaveBeenCalledTimes(1); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("spacePunctuation visible at levels 1 and 2 but not 0", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["delta"], + separatorMode: "spacePunctuation", + }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Level 0: no items (spacePunctuation not at level 0). + // Skip-ahead to level 1. B2 at anchor → hidden. + expect(menu.isActive()).toBe(false); + + // Level 1 (space): visible. + // No extra setChoices — trie was already loaded at level 1 by startNewSession. + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).not.toHaveBeenCalled(); + + // Backspace → hidden (B2 at anchor). + session.update("play", getPos); + expect(menu.isActive()).toBe(false); + // No setChoices on B2 — trie stays loaded at level 1. + expect(menu.setChoices).not.toHaveBeenCalled(); + + // Level 2 (punctuation): visible. Widen from 1→2. + menu.setChoices.mockClear(); + session.update("play.", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).toHaveBeenCalledTimes(1); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("none mode only visible at level 0", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [{ completions: ["epsilon"], separatorMode: "none" }], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Level 0: visible. + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenLastCalledWith( + "", + expect.anything(), + ); + + // Level 1 (space): "none" mode is NOT visible at level 1. + // Trie at level 0 has "epsilon", stripped prefix at level 0 = " ". + // " " doesn't match "epsilon" → C3 fails. + // D1: sepLevel(1) > menuSepLevel(0) → widen to level 1. + // Level 1: no items for "none" mode. Empty trie. + // D4: accept (closedSet=true). + // Widen loaded empty trie at level 1 — exactly 1 setChoices. + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(false); + expect(menu.setChoices).toHaveBeenCalledTimes(1); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); From 497564cf42dc95eb3ae9dbb4a4482f33f92bd1da Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 12:02:41 -0700 Subject: [PATCH 03/10] Propagate spacingMode through completion pipeline; optimize shell level counts Thread grammar-level spacingMode from GrammarCompletionProperty through the cache layer (CompletionProperty) into the dispatcher, so entity and parameter completions receive context-sensitive separator modes instead of a hardcoded "space". - actionGrammar: add spacingMode to GrammarCompletionProperty; pass it through getGrammarCompletionProperty; export candidateSeparatorMode, requiresSeparator, and GrammarCompletionProperty; remove unused mergeSeparatorMode, hasTrailingSeparator, and isRequiringSepMode. - cache: propagate spacingMode in grammarStore and CompletionProperty. - dispatcher: add completionSeparatorMode() helper; partition parameter completions by computed SeparatorMode per (property, mode) pair. - shell: precompute levelCounts array via setPartitions() to avoid rebuilding arrays on every keystroke; refactor targetLevel and lowestLevelWithItems to use LevelCounts. - Update tests for new separator mode defaults and property assertions. --- .../actionGrammar/src/grammarCompletion.ts | 20 ++---- .../actionGrammar/src/grammarMatcher.ts | 39 ----------- ts/packages/actionGrammar/src/index.ts | 4 +- ts/packages/actionGrammar/test/testUtils.ts | 35 +++++++++- ts/packages/cache/src/cache/grammarStore.ts | 2 + .../src/constructions/constructionCache.ts | 2 + .../src/translation/requestCompletion.ts | 69 ++++++++++++++++--- .../dispatcher/test/completion.spec.ts | 24 +++---- .../renderer/src/partialCompletionSession.ts | 62 +++++++++++------ 9 files changed, 160 insertions(+), 97 deletions(-) diff --git a/ts/packages/actionGrammar/src/grammarCompletion.ts b/ts/packages/actionGrammar/src/grammarCompletion.ts index 37ab2955e..89a029266 100644 --- a/ts/packages/actionGrammar/src/grammarCompletion.ts +++ b/ts/packages/actionGrammar/src/grammarCompletion.ts @@ -289,6 +289,7 @@ function findPartialKeywordInWildcard( export type GrammarCompletionProperty = { match: unknown; propertyNames: string[]; + spacingMode?: CompiledSpacingMode | undefined; // undefined = auto (default) }; // Describes how the grammar rules that produced completions at this @@ -361,6 +362,8 @@ export type GrammarCompletionResult = { // A completion group within a GrammarCompletionResult. // Intentionally parallel to CompletionGroup in @typeagent/agent-sdk // but defined here because actionGrammar has no dependency on agentSdk. +// Unlike the SDK's CompletionGroup (where separatorMode is optional, +// defaulting to "space"), the grammar always sets this field. export type GrammarCompletionGroup = { completions: string[]; separatorMode: SeparatorMode; @@ -369,6 +372,7 @@ export type GrammarCompletionGroup = { function getGrammarCompletionProperty( state: MatchState, valueId: number, + spacingMode: CompiledSpacingMode, ): GrammarCompletionProperty | undefined { const temp = { ...state }; @@ -392,6 +396,7 @@ function getGrammarCompletionProperty( return { match, propertyNames: wildcardPropertyNames, + spacingMode, }; } @@ -625,14 +630,6 @@ type DeferredShadowCandidate = { candidate: FixedCandidate; }; -// True when a separator character (whitespace or punctuation) exists -// at the given position in the input. Used by both within-grammar -// conflict filtering (filterSepConflicts) and cross-grammar conflict -// filtering (grammarStore) to determine trailing separator state. -export function hasTrailingSeparator(input: string, position: number): boolean { - return nextNonSeparatorIndex(input, position) > position; -} - // True when a separator is needed between the character at // `position - 1` in `input` and `firstCompletionChar` according // to `spacingMode`. Used by computeCandidateSeparatorMode. @@ -662,11 +659,6 @@ function computeCandidateSeparatorMode( ); } -// True when a SeparatorMode requires a separator character to be present. -export function isRequiringSepMode(mode: SeparatorMode): boolean { - return mode === "space" || mode === "spacePunctuation"; -} - // --- CompletionContext: mutable state shared across completion phases --- // // The main loop (Phase 1) collects lightweight candidate descriptors @@ -1617,6 +1609,7 @@ function materializeCandidates( const completionProperty = getGrammarCompletionProperty( c.state, c.valueId, + c.spacingMode, ); if (completionProperty !== undefined) { properties.push(completionProperty); @@ -1696,6 +1689,7 @@ function materializeCandidates( const completionProperty = getGrammarCompletionProperty( c.state, c.valueId, + c.spacingMode, ); if (completionProperty !== undefined) { properties.push(completionProperty); diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 61c00a4fe..710bedf30 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -122,45 +122,6 @@ export function candidateSeparatorMode( return "optionalSpace"; } -// Merge a new candidate's separator mode into the running aggregate. -// The mode requiring the strongest separator wins (i.e. the mode that -// demands the most from the user): -// space > spacePunctuation > optionalSpacePunctuation > optional > none. -export function mergeSeparatorMode( - current: SeparatorMode | undefined, - needsSep: boolean, - spacingMode: CompiledSpacingMode, -): SeparatorMode { - const candidateMode = candidateSeparatorMode(needsSep, spacingMode); - if (current === undefined) { - return candidateMode; - } - // "space" requires strict whitespace — strongest requirement. - if (current === "space" || candidateMode === "space") { - return "space"; - } - // "spacePunctuation" requires a separator — next strongest. - if ( - current === "spacePunctuation" || - candidateMode === "spacePunctuation" - ) { - return "spacePunctuation"; - } - // "optionalSpacePunctuation" — separator not required but includes - // punctuation when present. Stronger than plain "optionalSpace". - if ( - current === "optionalSpacePunctuation" || - candidateMode === "optionalSpacePunctuation" - ) { - return "optionalSpacePunctuation"; - } - // "optionalSpace" is a stronger requirement than "none". - if (current === "optionalSpace" || candidateMode === "optionalSpace") { - return "optionalSpace"; - } - return "none"; -} - export function isBoundarySatisfied( request: string, index: number, diff --git a/ts/packages/actionGrammar/src/index.ts b/ts/packages/actionGrammar/src/index.ts index cf4995011..ea41eca9d 100644 --- a/ts/packages/actionGrammar/src/index.ts +++ b/ts/packages/actionGrammar/src/index.ts @@ -24,16 +24,16 @@ export type { export { writeGrammarRules } from "./grammarRuleWriter.js"; export { matchGrammar, GrammarMatchResult } from "./grammarMatcher.js"; +export { candidateSeparatorMode, requiresSeparator } from "./grammarMatcher.js"; export { matchGrammarCompletion, GrammarCompletionResult, - isRequiringSepMode, - hasTrailingSeparator, } from "./grammarCompletion.js"; export type { AfterWildcard, GrammarCompletionGroup, + GrammarCompletionProperty, } from "./grammarCompletion.js"; // Entity system diff --git a/ts/packages/actionGrammar/test/testUtils.ts b/ts/packages/actionGrammar/test/testUtils.ts index 735d7b8fb..5f8987183 100644 --- a/ts/packages/actionGrammar/test/testUtils.ts +++ b/ts/packages/actionGrammar/test/testUtils.ts @@ -6,6 +6,7 @@ import { matchGrammar } from "../src/grammarMatcher.js"; import { matchGrammarCompletion, type GrammarCompletionResult, + type GrammarCompletionProperty, } from "../src/grammarCompletion.js"; import { compileGrammarToNFA } from "../src/nfaCompiler.js"; import { matchGrammarWithNFA } from "../src/nfaMatcher.js"; @@ -579,7 +580,7 @@ export function expectMetadata( closedSet?: boolean; directionSensitive?: boolean; afterWildcard?: string; - properties?: unknown[]; + properties?: Partial[]; }, ): void { if ("groups" in expected && "completions" in expected) { @@ -642,6 +643,36 @@ export function expectMetadata( expect(result.afterWildcard).toBe(expected.afterWildcard); } if ("properties" in expected) { - expect(result.properties).toEqual(expected.properties); + // Sort properties by propertyNames so order is not significant. + const sortProps = ( + arr: T[], + ): T[] => + [...arr].sort((a, b) => + [...(a.propertyNames ?? [])] + .sort() + .join(",") + .localeCompare( + [...(b.propertyNames ?? [])].sort().join(","), + ), + ); + + // When the expected property objects omit spacingMode, strip it + // from actuals so existing tests don't break. Tests that want to + // assert spacingMode include it explicitly. + const checkSpacingMode = (expected.properties ?? []).some( + (p) => "spacingMode" in p, + ); + if (checkSpacingMode) { + expect(sortProps(result.properties ?? [])).toEqual( + sortProps(expected.properties ?? []), + ); + } else { + const stripped = (result.properties ?? []).map( + ({ spacingMode: _, ...rest }) => rest, + ); + expect(sortProps(stripped)).toEqual( + sortProps(expected.properties ?? []), + ); + } } } diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 3c015e187..caafe9c54 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -374,6 +374,7 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: p.propertyNames, + spacingMode: p.spacingMode, }); } } @@ -452,6 +453,7 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: p.propertyNames, + spacingMode: p.spacingMode, }); } } diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 1314be9ff..6fd109e0c 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -7,6 +7,7 @@ import { SeparatorMode, AfterWildcard, } from "@typeagent/agent-sdk"; +import type { CompiledSpacingMode } from "action-grammar"; import { ExecutableAction, HistoryContext, @@ -80,6 +81,7 @@ export type MatchOptions = { export type CompletionProperty = { actions: ExecutableAction[]; names: string[]; + spacingMode?: CompiledSpacingMode | undefined; // undefined = auto (default) }; export type CompletionResult = { diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index f2b423467..9c771454a 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -8,8 +8,11 @@ import { CompletionDirection, CompletionGroup, CompletionGroups, + SeparatorMode, TypeAgentAction, } from "@typeagent/agent-sdk"; +import type { CompiledSpacingMode } from "action-grammar"; +import { candidateSeparatorMode, requiresSeparator } from "action-grammar"; import { DeepPartialUndefined } from "@typeagent/common-utils"; import { ActionParamType, @@ -126,6 +129,9 @@ export async function requestCompletion( completionProperty.actions, context, propertyCompletions, + completionProperty.spacingMode, + input, + matchedPrefixLength ?? 0, ); } } @@ -140,11 +146,31 @@ export async function requestCompletion( }; } +// Compute the SeparatorMode for a completion string given the +// spacing mode, the input text, and the matched prefix length. +function completionSeparatorMode( + completion: string, + spacingMode: CompiledSpacingMode, + input: string, + prefixLength: number, +): SeparatorMode { + if (prefixLength <= 0 || completion.length === 0) { + return spacingMode === "none" ? "none" : "optionalSpace"; + } + const needsSep = + spacingMode !== "none" && + requiresSeparator(input[prefixLength - 1], completion[0], spacingMode); + return candidateSeparatorMode(needsSep, spacingMode); +} + async function collectActionCompletions( properties: string[], partialActions: ExecutableAction[], context: CommandHandlerContext, propertyCompletions: Map, + spacingMode: CompiledSpacingMode, + input: string, + matchedPrefixLength: number, ) { for (const propertyName of properties) { const { action, parameterName } = getPropertyInfo( @@ -164,14 +190,41 @@ async function collectActionCompletions( ); if (paramCompletion !== undefined) { - propertyCompletions.set(propertyName, { - name: `property ${propertyName}`, - ...paramCompletion, - separatorMode: "space", - needQuotes: false, // Request completions are partial, no quotes needed - sorted: true, // REVIEW: assume property completions are already in desired order by the agent. - kind: "entity", - }); + // Partition completions by their computed separator mode, + // creating one CompletionGroup per (property, mode) pair. + const byMode = new Map(); + for (const c of paramCompletion.completions) { + const mode = completionSeparatorMode( + c, + spacingMode, + input, + matchedPrefixLength, + ); + let bucket = byMode.get(mode); + if (bucket === undefined) { + bucket = []; + byMode.set(mode, bucket); + } + bucket.push(c); + } + + for (const [mode, completions] of byMode) { + // Use a composite key so multiple modes from the same + // property don't overwrite each other. + const key = + byMode.size === 1 + ? propertyName + : `${propertyName}:${mode}`; + propertyCompletions.set(key, { + name: `property ${propertyName}`, + completions, + emojiChar: paramCompletion.emojiChar, + separatorMode: mode, + needQuotes: false, // Request completions are partial, no quotes needed + sorted: true, // REVIEW: assume property completions are already in desired order by the agent. + kind: "entity", + }); + } } } } diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index f47b1eb5b..5ade4a12e 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -800,9 +800,8 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // "run" is the default subcommand, so subcommand alternatives - // are included and the group has separatorMode: "space". - // Subcommand completions at the boundary retain "space". - expect(result!.completions[0].separatorMode).toBe("space"); + // are included. separatorMode defaults to undefined ("space"). + expect(result!.completions[0].separatorMode).toBeUndefined(); // startIndex includes trailing whitespace. expect(result!.startIndex).toBe(10); }); @@ -814,7 +813,7 @@ describe("Command Completion - startIndex", () => { context, ); expect(result).toBeDefined(); - expect(result!.completions[0].separatorMode).toBe("space"); + expect(result!.completions[0].separatorMode).toBeUndefined(); expect(result!.startIndex).toBe(9); // Default subcommand has agent completions → not exhaustive. expect(result!.closedSet).toBe(false); @@ -824,13 +823,13 @@ describe("Command Completion - startIndex", () => { const result = await getCommandCompletion("@", "forward", context); expect(result).toBeDefined(); // Top-level completions (agent names, system subcommands) - // follow '@' — baseMode is "space" since there's no trailing - // whitespace after the '@' marker. + // follow '@' — agent names use optionalSpace since the '@' + // marker doesn't require whitespace before the name. const agentNamesGroup = result!.completions.find( (g) => g.name === "Agent Names", ); expect(agentNamesGroup).toBeDefined(); - expect(agentNamesGroup!.separatorMode).toBe("space"); + expect(agentNamesGroup!.separatorMode).toBe("optionalSpace"); expect(agentNamesGroup!.completions).toContain("comptest"); // Subcommand + agent name sets are finite → exhaustive. expect(result!.closedSet).toBe(true); @@ -844,8 +843,9 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // Partial parameter token — only parameter completions returned, - // no subcommand group. Each group carries its own separatorMode. - expect(result!.completions[0].separatorMode).toBe("space"); + // no subcommand group. Each group carries its own separatorMode + // (undefined means "space" — the documented default). + expect(result!.completions[0].separatorMode).toBeUndefined(); }); it("returns no separatorMode for partial unmatched token consumed as param", async () => { @@ -858,13 +858,13 @@ describe("Command Completion - startIndex", () => { // "ne" is fully consumed as the "task" arg by parameter // parsing. No trailing space. startIndex = 10 // (after "@comptest "), which is ≤ commandConsumedLength - // (10), so sibling subcommands are included with - // separatorMode="space". + // (10), so sibling subcommands are included (separatorMode + // defaults to undefined/"space"). const subcommands = result!.completions.find( (g) => g.name === "Subcommands", ); expect(subcommands).toBeDefined(); - expect(subcommands!.separatorMode).toBe("space"); + expect(subcommands!.separatorMode).toBeUndefined(); expect(result!.startIndex).toBe(10); }); }); diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index a198a76b2..3bae4bf37 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -98,6 +98,9 @@ export class PartialCompletionSession { // Items partitioned by separatorMode from the last result. private partitions: ItemPartition[] = []; + // Precomputed item counts per SepLevel. Updated whenever + // partitions change (setPartitions / resetToIdle). + private levelCounts: LevelCounts = [0, 0, 0]; // The SepLevel at which the trie is currently loaded. // Items in the trie correspond to itemsAtLevel(partitions, menuSepLevel). // The trie is only reloaded when: (a) the user narrows past the @@ -132,6 +135,12 @@ export class PartialCompletionSession { this.menu.setChoices(itemsAtLevel(this.partitions, level)); } + // Update partitions and recompute levelCounts. + private setPartitions(partitions: ItemPartition[]): void { + this.partitions = partitions; + this.levelCounts = computeLevelCounts(partitions); + } + // Strip the separator from rawPrefix at the current menuSepLevel, // compute the menu position, and update the trie prefix. Returns // true when the prefix uniquely satisfies one entry; hides the menu @@ -181,7 +190,7 @@ export class PartialCompletionSession { public resetToIdle(): void { this.anchor = undefined; this.completionP = undefined; - this.partitions = []; + this.setPartitions([]); this.menuSepLevel = 0; this.explicitCloseAnchor = undefined; } @@ -217,7 +226,7 @@ export class PartialCompletionSession { const rawPrefix = input.substring(this.anchor.length); const sepLevel = computeSepLevel(rawPrefix); if (sepLevel !== this.menuSepLevel) { - const newLevel = targetLevel(this.partitions, sepLevel); + const newLevel = targetLevel(this.levelCounts, sepLevel); if (newLevel !== this.menuSepLevel) { this.loadLevel(newLevel); this.positionMenu(rawPrefix, getPosition); @@ -249,7 +258,7 @@ export class PartialCompletionSession { if (computeSepLevel(rawPrefix) < this.menuSepLevel) { return undefined; } - if (itemsAtLevel(this.partitions, this.menuSepLevel).length === 0) { + if (this.levelCounts[this.menuSepLevel] === 0) { return undefined; } return stripAtLevel(rawPrefix, this.menuSepLevel); @@ -345,7 +354,7 @@ export class PartialCompletionSession { // ── B. Menu anchor — is the trie at the right level? ───────── if (sepLevel < this.menuSepLevel) { - if (itemsAtLevel(this.partitions, sepLevel).length > 0) { + if (this.levelCounts[sepLevel] > 0) { // [B1] NARROW — user backed into a lower level that has items. this.loadLevel(sepLevel); // Fall through to C. @@ -376,10 +385,7 @@ export class PartialCompletionSession { // At the anchor with no items loaded — hide and wait. // Covers error-recovery (anchor preserved but no data) and // genuinely empty results at rawPrefix="". - if ( - rawPrefix === "" && - itemsAtLevel(this.partitions, this.menuSepLevel).length === 0 - ) { + if (rawPrefix === "" && this.levelCounts[this.menuSepLevel] === 0) { debug( `Partial completion deferred: no items at menuSepLevel=${this.menuSepLevel}`, ); @@ -461,7 +467,7 @@ export class PartialCompletionSession { this.menu.hide(); this.menu.setChoices([]); this.anchor = input; - this.partitions = []; + this.setPartitions([]); this.menuSepLevel = 0; this.noMatchPolicy = "refetch"; const completionP = this.dispatcher.getCommandCompletion( @@ -491,7 +497,7 @@ export class PartialCompletionSession { debug( `Partial completion skipped: No completions for '${input}'`, ); - this.partitions = []; + this.setPartitions([]); return; } @@ -502,14 +508,14 @@ export class PartialCompletionSession { ? input.substring(0, result.startIndex) : input; this.anchor = partial; - this.partitions = partitions; + this.setPartitions(partitions); // Pick the best trie level for the caller's input: // highest level ≤ inputSepLevel with items, falling // back to lowestLevelWithItems for skip-ahead. const rawPrefix = input.substring(partial.length); this.loadLevel( - targetLevel(partitions, computeSepLevel(rawPrefix)), + targetLevel(this.levelCounts, computeSepLevel(rawPrefix)), ); // If triggered by an explicit close, only reopen when the @@ -609,6 +615,7 @@ function isModeAtLevel( // Returns items from partitions whose separatorMode belongs to the // given level. Non-cumulative — each level has its own item set. +// Only called by loadLevel (which needs the actual items). function itemsAtLevel( partitions: ItemPartition[], level: SepLevel, @@ -624,11 +631,27 @@ function itemsAtLevel( return result; } +// Precomputed item counts per SepLevel. Avoids rebuilding arrays +// on every keystroke for the many count-only checks. +type LevelCounts = [number, number, number]; + +function computeLevelCounts(partitions: ItemPartition[]): LevelCounts { + const counts: LevelCounts = [0, 0, 0]; + for (const p of partitions) { + for (let level = 0; level <= 2; level++) { + if (isModeAtLevel(p.mode, level as SepLevel)) { + counts[level] += p.items.length; + } + } + } + return counts; +} + // Returns the lowest SepLevel that has items. Used for skip-ahead: // when no items exist at level 0, jump to the first level that does. -function lowestLevelWithItems(partitions: ItemPartition[]): SepLevel { - for (let level: SepLevel = 0; level <= 2; level++) { - if (itemsAtLevel(partitions, level as SepLevel).length > 0) { +function lowestLevelWithItems(counts: LevelCounts): SepLevel { + for (let level = 0; level <= 2; level++) { + if (counts[level] > 0) { return level as SepLevel; } } @@ -638,16 +661,13 @@ function lowestLevelWithItems(partitions: ItemPartition[]): SepLevel { // Returns the highest SepLevel ≤ maxLevel that has items, falling back // to lowestLevelWithItems when nothing exists at or below maxLevel // (e.g. entities that only appear at level 1+). -function targetLevel( - partitions: ItemPartition[], - maxLevel: SepLevel, -): SepLevel { +function targetLevel(counts: LevelCounts, maxLevel: SepLevel): SepLevel { for (let l = maxLevel; l > 0; l--) { - if (itemsAtLevel(partitions, l as SepLevel).length > 0) { + if (counts[l] > 0) { return l as SepLevel; } } - return lowestLevelWithItems(partitions); + return lowestLevelWithItems(counts); } // Strip leading separator characters from rawPrefix based on the level. From c6afde7163a7a2098f2e3cf5c82c9b01f6d9565b Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 13:13:18 -0700 Subject: [PATCH 04/10] Defer autoSpacePunctuation resolution from backend to shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of the grammar/cache layers eagerly resolving auto-spacing into concrete SeparatorMode values (spacePunctuation or optionalSpacePunctuation) at match time, pass 'autoSpacePunctuation' through as a new SeparatorMode value. The shell renderer resolves it per-item based on the character pair at startIndex. - Add 'autoSpacePunctuation' to SeparatorMode in agentSdk and actionGrammar - Replace computeCandidateSeparatorMode/computeNeedsSep with spacingModeToSeparatorMode in grammarCompletion - Change GrammarCompletionProperty.spacingMode to separatorMode - Update CompletionProperty in cache to use separatorMode instead of spacingMode (removes CompiledSpacingMode dependency) - Simplify collectActionCompletions in dispatcher — pass separatorMode through instead of partitioning per-completion - Inline needsSeparatorInAutoMode in partialCompletionSession.ts to avoid importing action-grammar into the Vite browser bundle - Update toPartitions to resolve autoSpacePunctuation per-item - Update all test expectations accordingly --- .../actionGrammar/src/grammarCompletion.ts | 64 ++-- .../actionGrammar/src/grammarMatcher.ts | 7 +- ts/packages/actionGrammar/src/index.ts | 3 +- .../actionGrammar/src/nfaCompletion.ts | 1 + ...mmarCompletionCategory3bLimitation.spec.ts | 10 +- ...grammarCompletionKeywordSpacePunct.spec.ts | 288 +++++++++--------- .../grammarCompletionLongestMatch.spec.ts | 116 +++---- ...ammarCompletionMultiWordKeywordEOI.spec.ts | 32 +- .../grammarCompletionNestedWildcard.spec.ts | 4 +- .../grammarCompletionPrefixLength.spec.ts | 224 +++++++------- .../grammarCompletionSpacingNested.spec.ts | 32 +- ts/packages/actionGrammar/test/testUtils.ts | 22 +- ts/packages/agentSdk/src/command.ts | 9 +- ts/packages/cache/src/cache/grammarStore.ts | 5 +- .../src/constructions/constructionCache.ts | 8 +- ts/packages/cache/test/completion.spec.ts | 2 +- .../cache/test/crossGrammarConflict.spec.ts | 6 +- .../cache/test/mergeCompletionResults.spec.ts | 4 + .../src/translation/requestCompletion.ts | 71 +---- .../renderer/src/partialCompletionSession.ts | 93 +++++- .../test/partialCompletion/grammarE2E.spec.ts | 4 +- 21 files changed, 504 insertions(+), 501 deletions(-) diff --git a/ts/packages/actionGrammar/src/grammarCompletion.ts b/ts/packages/actionGrammar/src/grammarCompletion.ts index 89a029266..9f47779ed 100644 --- a/ts/packages/actionGrammar/src/grammarCompletion.ts +++ b/ts/packages/actionGrammar/src/grammarCompletion.ts @@ -11,7 +11,6 @@ import { type SeparatorMode, separatorRegExpStr, requiresSeparator, - candidateSeparatorMode, isBoundarySatisfied, nextNonSeparatorIndex, getWildcardStr, @@ -289,7 +288,7 @@ function findPartialKeywordInWildcard( export type GrammarCompletionProperty = { match: unknown; propertyNames: string[]; - spacingMode?: CompiledSpacingMode | undefined; // undefined = auto (default) + separatorMode: SeparatorMode; }; // Describes how the grammar rules that produced completions at this @@ -396,7 +395,7 @@ function getGrammarCompletionProperty( return { match, propertyNames: wildcardPropertyNames, - spacingMode, + separatorMode: spacingModeToSeparatorMode(spacingMode), }; } @@ -630,33 +629,22 @@ type DeferredShadowCandidate = { candidate: FixedCandidate; }; -// True when a separator is needed between the character at -// `position - 1` in `input` and `firstCompletionChar` according -// to `spacingMode`. Used by computeCandidateSeparatorMode. -function computeNeedsSep( - input: string, - position: number, - firstCompletionChar: string, - spacingMode: CompiledSpacingMode, -): boolean { - return ( - position > 0 && - spacingMode !== "none" && - requiresSeparator(input[position - 1], firstCompletionChar, spacingMode) - ); -} - -// Compute a candidate's individual SeparatorMode at a given position. -function computeCandidateSeparatorMode( - input: string, - position: number, - firstCompletionChar: string, - spacingMode: CompiledSpacingMode, +// Map a compiled spacing mode to the corresponding SeparatorMode. +// Only "autoSpacePunctuation" (undefined) produces a mode that requires per-item +// resolution by the consumer; the other modes map deterministically. +export function spacingModeToSeparatorMode( + mode: CompiledSpacingMode, ): SeparatorMode { - return candidateSeparatorMode( - computeNeedsSep(input, position, firstCompletionChar, spacingMode), - spacingMode, - ); + switch (mode) { + case "required": + return "spacePunctuation"; + case "optional": + return "optionalSpacePunctuation"; + case "none": + return "none"; + case undefined: // auto + return "autoSpacePunctuation"; + } } // --- CompletionContext: mutable state shared across completion phases --- @@ -1598,13 +1586,10 @@ function materializeCandidates( partialKeywordBackup = true; } if (c.kind === "string") { - const mode = computeCandidateSeparatorMode( - input, - ctx.maxPrefixLength, - c.completionText[0], - c.spacingMode, + addCompletion( + c.completionText, + spacingModeToSeparatorMode(c.spacingMode), ); - addCompletion(c.completionText, mode); } else { const completionProperty = getGrammarCompletionProperty( c.state, @@ -1676,13 +1661,10 @@ function materializeCandidates( partial !== undefined && !hasCompletion(partial.remainingText) ) { - const mode = computeCandidateSeparatorMode( - input, - ctx.maxPrefixLength, - partial.remainingText[0], - c.spacingMode, + addCompletion( + partial.remainingText, + spacingModeToSeparatorMode(c.spacingMode), ); - addCompletion(partial.remainingText, mode); anyAfterWildcard = true; } } else { diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 710bedf30..93eff3239 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -18,15 +18,14 @@ import { // Separator mode for completion results. Structurally identical to // SeparatorMode from @typeagent/agent-sdk (command.ts); independently // defined here so actionGrammar does not depend on agentSdk. Keep -// both definitions in sync. The grammar matcher only produces -// "spacePunctuation", "optionalSpacePunctuation", "optionalSpace", and -// "none" — never "space" (which is strictly command/flag-level). +// both definitions in sync. export type SeparatorMode = | "space" | "spacePunctuation" | "optionalSpacePunctuation" | "optionalSpace" - | "none"; + | "none" + | "autoSpacePunctuation"; const debugMatchRaw = registerDebug("typeagent:grammar:match"); diff --git a/ts/packages/actionGrammar/src/index.ts b/ts/packages/actionGrammar/src/index.ts index ea41eca9d..58fa31a80 100644 --- a/ts/packages/actionGrammar/src/index.ts +++ b/ts/packages/actionGrammar/src/index.ts @@ -24,11 +24,12 @@ export type { export { writeGrammarRules } from "./grammarRuleWriter.js"; export { matchGrammar, GrammarMatchResult } from "./grammarMatcher.js"; -export { candidateSeparatorMode, requiresSeparator } from "./grammarMatcher.js"; +export { needsSeparatorInAutoMode } from "./grammarMatcher.js"; export { matchGrammarCompletion, GrammarCompletionResult, + spacingModeToSeparatorMode, } from "./grammarCompletion.js"; export type { AfterWildcard, diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index 394520092..25454947e 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -337,6 +337,7 @@ function buildGrammarProperties( parameters: {}, }, propertyNames: [prop.propertyPath], + separatorMode: "autoSpacePunctuation", }); } diff --git a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts index 340c27b7b..680d4ae47 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionCategory3bLimitation.spec.ts @@ -172,7 +172,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["midi", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", }); }); @@ -183,7 +183,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["midi", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", }); }); }); @@ -205,7 +205,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["映画", "音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", }); }); }); @@ -224,7 +224,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", }); }); }); @@ -280,7 +280,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", }); }); }); diff --git a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts index 36ba0505f..471e49806 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionKeywordSpacePunct.spec.ts @@ -41,7 +41,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -57,7 +57,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -74,7 +74,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -87,7 +87,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -103,7 +103,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -117,7 +117,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -132,7 +132,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -155,7 +155,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -170,7 +170,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -186,7 +186,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -199,7 +199,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -213,7 +213,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -236,7 +236,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -251,7 +251,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -266,7 +266,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -279,7 +279,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 7, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -293,7 +293,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 7, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -316,7 +316,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -330,7 +330,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -344,7 +344,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -357,7 +357,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -373,7 +373,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -386,7 +386,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -403,7 +403,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -427,7 +427,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -443,7 +443,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -458,7 +458,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -493,7 +493,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -509,7 +509,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -524,7 +524,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -543,7 +543,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -560,7 +560,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -580,7 +580,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -607,7 +607,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -626,7 +626,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -647,7 +647,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -665,7 +665,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" world"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -683,7 +683,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -701,7 +701,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -837,7 +837,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -851,7 +851,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -864,7 +864,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello-world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -879,7 +879,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -902,7 +902,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["set:"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -917,7 +917,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -930,7 +930,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -945,7 +945,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -959,7 +959,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["value"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -982,7 +982,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -996,7 +996,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1010,7 +1010,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 8, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1023,7 +1023,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 9, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1046,7 +1046,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1064,7 +1064,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1085,7 +1085,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1098,7 +1098,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1125,7 +1125,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1140,7 +1140,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1157,7 +1157,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",done"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1186,7 +1186,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1207,7 +1207,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1229,7 +1229,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1263,7 +1263,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [".world"], matchedPrefixLength: 10, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1278,7 +1278,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [".world"], matchedPrefixLength: 10, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1307,7 +1307,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1326,7 +1326,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1347,7 +1347,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1365,7 +1365,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1396,7 +1396,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1414,7 +1414,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1438,7 +1438,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,", "hello."], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1453,7 +1453,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1468,7 +1468,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1493,7 +1493,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1507,7 +1507,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1521,7 +1521,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1543,7 +1543,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "spacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1725,7 +1725,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1740,7 +1740,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1761,7 +1761,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1793,7 +1793,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1807,7 +1807,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world!"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1821,7 +1821,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["thanks."], matchedPrefixLength: 13, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1838,7 +1838,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["thanks."], matchedPrefixLength: 13, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1873,7 +1873,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1895,7 +1895,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1922,7 +1922,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 10, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1949,7 +1949,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["worldly"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1966,7 +1966,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["things"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1993,7 +1993,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2007,7 +2007,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2031,7 +2031,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["don't"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2045,7 +2045,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["stop"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2063,7 +2063,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["stop"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2087,7 +2087,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["price"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2100,7 +2100,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1.99"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2113,7 +2113,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 10, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2135,7 +2135,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2148,7 +2148,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2179,7 +2179,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2204,7 +2204,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2222,7 +2222,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2246,7 +2246,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,", "shuffle"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2260,7 +2260,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 11, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2274,7 +2274,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2300,7 +2300,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2315,7 +2315,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2341,7 +2341,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["."], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2366,7 +2366,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["v1.0"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2379,7 +2379,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["is"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2393,7 +2393,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["released"], matchedPrefixLength: 7, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2416,7 +2416,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2429,7 +2429,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2444,7 +2444,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2468,7 +2468,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2482,7 +2482,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello,"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2504,7 +2504,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "spacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2558,7 +2558,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2576,7 +2576,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2600,7 +2600,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2615,7 +2615,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2645,7 +2645,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 15, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2666,7 +2666,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2692,7 +2692,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello, world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2706,7 +2706,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2741,7 +2741,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2762,7 +2762,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2786,7 +2786,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [",world"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2804,7 +2804,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2829,7 +2829,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [","], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2852,7 +2852,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: false, afterWildcard: "none", @@ -2885,7 +2885,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2908,7 +2908,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [" done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2934,7 +2934,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2955,7 +2955,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -2980,7 +2980,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3001,7 +3001,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3024,7 +3024,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["-done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3050,7 +3050,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done!"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3070,7 +3070,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done!"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3092,7 +3092,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done!"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3127,7 +3127,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3148,7 +3148,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3168,7 +3168,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 3, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 5749da259..1000206df 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -22,7 +22,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["first"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -35,7 +35,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["second"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -48,7 +48,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["second"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -61,7 +61,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["third"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -74,7 +74,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["third"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -91,7 +91,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["third"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -111,7 +111,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["third"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -127,7 +127,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["third"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -154,7 +154,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["delta"], matchedPrefixLength: 19, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -167,7 +167,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["charlie"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -196,7 +196,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["charlie"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -210,7 +210,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["bravo"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -233,7 +233,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["suffix_x", "suffix_y"], matchedPrefixLength: 6, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -252,7 +252,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["suffix_x", "suffix_y"], matchedPrefixLength: 6, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -276,7 +276,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["middle", "finish"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -289,7 +289,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["finish"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -308,7 +308,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["finish"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -328,7 +328,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["percent"], matchedPrefixLength: 13, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -344,7 +344,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["percent"], matchedPrefixLength: 13, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -361,7 +361,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["percent"], matchedPrefixLength: 13, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -386,7 +386,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -401,7 +401,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -415,7 +415,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -428,7 +428,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -443,7 +443,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["rock", "pop", "jazz"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -464,7 +464,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play"); expectMetadata(result, { matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -482,7 +482,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -494,7 +494,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play hello by"); expectMetadata(result, { matchedPrefixLength: 13, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", @@ -521,7 +521,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["deep"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -534,7 +534,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -553,7 +553,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -567,7 +567,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world", "earth"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -581,7 +581,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done", "world", "earth"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -597,7 +597,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done", "world", "earth"], matchedPrefixLength: 17, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -633,7 +633,7 @@ describeForEachCompletion( // "gamma" must appear as completion from the longer match. expectMetadata(result, { completions: ["gamma"], - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -649,7 +649,7 @@ describeForEachCompletion( // We verify maxPrefixLength is from the longest match. expectMetadata(result, { matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -670,7 +670,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["World"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -683,7 +683,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["World"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -696,7 +696,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["World"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -721,7 +721,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["rock"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -734,7 +734,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -750,7 +750,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play", "stop"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -773,7 +773,7 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play"); expectMetadata(result, { matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -791,7 +791,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -816,7 +816,7 @@ describeForEachCompletion( const r3 = matchGrammarCompletion(grammar, "one "); const shared = { completions: ["two"], - separatorMode: "spacePunctuation" as const, + separatorMode: "autoSpacePunctuation" as const, closedSet: true, afterWildcard: "none", properties: [], @@ -859,7 +859,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -883,7 +883,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -920,7 +920,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["finish"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -945,7 +945,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -961,7 +961,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 21, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -983,7 +983,7 @@ describeForEachCompletion( ); expectMetadata(result, { matchedPrefixLength: 13, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", @@ -1010,7 +1010,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 13, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", @@ -1088,7 +1088,7 @@ describeForEachCompletion( ); expectMetadata(result, { matchedPrefixLength: 17, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", @@ -1136,7 +1136,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 13, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "all", @@ -1162,7 +1162,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1180,7 +1180,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1196,7 +1196,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 21, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1214,7 +1214,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 21, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", diff --git a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts index fe46a1773..46d08f17c 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionMultiWordKeywordEOI.spec.ts @@ -34,7 +34,7 @@ describeForEachCompletion( it('forward: "play something played" — should offer "by", not "played"', () => { // Buggy result: // completions: ["played"], matchedPrefixLength: 21, - // separatorMode: "spacePunctuation", afterWildcard: "all" + // separatorMode: "autoSpacePunctuation", afterWildcard: "all" // // Correct: "played" is keyword word 0, fully matched to EOI. // The completion should be "by" (keyword word 1) at @@ -48,7 +48,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 21, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -71,7 +71,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["good", "played"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -95,7 +95,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["good", "played"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -118,7 +118,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["good", "played"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -129,7 +129,7 @@ describeForEachCompletion( it('forward: "play something played " — first keyword word + space, should offer "by"', () => { // Buggy result: // completions: ["played"], matchedPrefixLength: 22, - // separatorMode: "optionalSpace", afterWildcard: "all" + // separatorMode: "autoSpacePunctuation", afterWildcard: "all" // // Correct: "played " with trailing space — the first keyword // word was fully matched. Should offer "by" at @@ -143,7 +143,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 22, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -166,7 +166,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 18, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -184,7 +184,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 17, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -204,7 +204,7 @@ describeForEachCompletion( // backs up to the wildcard start. expectMetadata(result, { matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -227,7 +227,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["played"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -249,7 +249,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -272,7 +272,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 17, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -297,7 +297,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["someone"], matchedPrefixLength: 20, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -319,7 +319,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -354,7 +354,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["safely"], matchedPrefixLength: 15, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", diff --git a/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts index 0657b185b..fd433c81a 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionNestedWildcard.spec.ts @@ -82,7 +82,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 15, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", afterWildcard: "all", }); }); @@ -92,7 +92,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", afterWildcard: "all", }); }); diff --git a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts index 1e0db7943..095d6981e 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionPrefixLength.spec.ts @@ -20,7 +20,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -33,7 +33,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -50,7 +50,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -65,7 +65,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -79,7 +79,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -93,7 +93,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -108,7 +108,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -131,7 +131,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -145,7 +145,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -158,7 +158,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -173,7 +173,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -186,7 +186,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -200,7 +200,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -223,7 +223,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "video"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -246,7 +246,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -259,7 +259,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -272,7 +272,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -290,7 +290,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -306,7 +306,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -333,7 +333,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["再生"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -347,7 +347,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -360,7 +360,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -374,7 +374,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -394,7 +394,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["再生"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -409,7 +409,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -429,7 +429,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -441,7 +441,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["停止"], matchedPrefixLength: 8, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -464,7 +464,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -487,7 +487,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 4, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -553,7 +553,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -574,7 +574,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -607,7 +607,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -626,7 +626,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -651,7 +651,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -669,7 +669,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -697,7 +697,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -715,7 +715,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -743,7 +743,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -770,7 +770,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -807,7 +807,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -828,7 +828,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -857,7 +857,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 16, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -877,7 +877,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["right"], matchedPrefixLength: 20, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -908,7 +908,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -934,7 +934,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 13, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -999,7 +999,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1019,7 +1019,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1037,7 +1037,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1055,7 +1055,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1076,7 +1076,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1098,7 +1098,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1128,7 +1128,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1165,7 +1165,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["songs"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1186,7 +1186,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -1212,7 +1212,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["songs"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -1241,7 +1241,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1259,7 +1259,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1278,7 +1278,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1518,7 +1518,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["停止"], matchedPrefixLength: 5, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1538,7 +1538,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["音楽"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1569,7 +1569,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1589,7 +1589,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1609,7 +1609,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1631,7 +1631,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello world"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1651,7 +1651,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1733,7 +1733,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1755,7 +1755,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["hello "], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1774,7 +1774,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1795,7 +1795,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1813,7 +1813,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1833,7 +1833,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["world"], matchedPrefixLength: 6, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1851,7 +1851,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1870,7 +1870,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["next"], matchedPrefixLength: 11, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1894,7 +1894,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1912,7 +1912,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1930,7 +1930,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1948,7 +1948,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1966,7 +1966,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1985,7 +1985,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music"], matchedPrefixLength: 12, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2005,7 +2005,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["shuffle", "music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2030,7 +2030,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2051,7 +2051,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -2076,7 +2076,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -2113,7 +2113,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "video"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2134,7 +2134,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "video"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2152,7 +2152,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "video"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2170,7 +2170,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["some"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2188,7 +2188,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2216,7 +2216,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -2243,7 +2243,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -2278,7 +2278,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now", "song"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2296,7 +2296,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["song"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2314,7 +2314,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now", "song"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2332,7 +2332,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now", "song"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2359,7 +2359,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["song", "now"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2377,7 +2377,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["now", "song"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2447,7 +2447,7 @@ describeForEachCompletion( expectMetadata(forward, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -2457,7 +2457,7 @@ describeForEachCompletion( expectMetadata(backward, { completions: ["play"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -2481,7 +2481,7 @@ describeForEachCompletion( expectMetadata(forward, { completions: ["music"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -3024,7 +3024,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3045,7 +3045,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["beautiful"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -3084,7 +3084,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["good", "by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3120,7 +3120,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 9, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3169,7 +3169,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3203,7 +3203,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3222,7 +3222,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3252,7 +3252,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["music", "by"], matchedPrefixLength: 14, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "some", @@ -3284,7 +3284,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 19, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3319,7 +3319,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["by"], matchedPrefixLength: 10, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "all", @@ -3459,7 +3459,7 @@ describeForEachCompletion( expectMetadata(result, { completions: [], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: false, directionSensitive: true, afterWildcard: "none", @@ -3532,7 +3532,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["..."], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -3549,7 +3549,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["done"], matchedPrefixLength: 3, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", diff --git a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts index 8e1876961..28b09e9db 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionSpacingNested.spec.ts @@ -112,13 +112,11 @@ describeForEachCompletion( const result = matchGrammarCompletion(grammar, "play "); // "play " → "play" matched, trailing separator consumed. // Next is whose first part is "ab". Leading - // separator mode = parent's auto mode (compiles to - // spacePunctuation; Latin→Latin requires space). - // But we already have a trailing separator. + // separator mode = parent's auto mode. expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 4, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -472,14 +470,14 @@ describeForEachCompletion( it("no trailing separator: keeps none-mode completions", () => { const result = matchGrammarCompletion(grammar, "ab"); - // Per-group: NoneRule → "none" group, AutoRule → "spacePunctuation" group. + // Per-group: NoneRule → "none" group, AutoRule → "auto" group. // No conflict filtering — both kept. expectMetadata(result, { groups: [ { completions: ["cd"], separatorMode: "none" }, { completions: ["cd"], - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", }, ], matchedPrefixLength: 2, @@ -498,7 +496,7 @@ describeForEachCompletion( { completions: ["cd"], separatorMode: "none" }, { completions: ["cd"], - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", }, ], matchedPrefixLength: 2, @@ -679,7 +677,7 @@ describeForEachCompletion( // Backward reconsiders the last matched word "ab". // Without a second rule pushing maxPrefixLength higher, // the backed-up P=0 candidate wins. - // separatorMode is "optionalSpace" because at P=0 the + // separatorMode is "autoSpacePunctuation" because at P=0 the // separator between cursor and completion is governed // by the parent Start rule (auto spacing), not // NoneRule's internal spacing. @@ -692,7 +690,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -732,7 +730,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -853,7 +851,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["gh"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -876,7 +874,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["cd", "ef"], matchedPrefixLength: 2, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -889,7 +887,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["cd", "ef"], matchedPrefixLength: 2, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -910,7 +908,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["ab"], matchedPrefixLength: 0, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: false, afterWildcard: "none", @@ -1013,7 +1011,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1cd", "1ef"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1028,7 +1026,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["gh"], matchedPrefixLength: 5, - separatorMode: "spacePunctuation", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", @@ -1051,7 +1049,7 @@ describeForEachCompletion( expectMetadata(result, { completions: ["1ef", "1cd"], matchedPrefixLength: 2, - separatorMode: "optionalSpace", + separatorMode: "autoSpacePunctuation", closedSet: true, directionSensitive: true, afterWildcard: "none", diff --git a/ts/packages/actionGrammar/test/testUtils.ts b/ts/packages/actionGrammar/test/testUtils.ts index 5f8987183..4acf77bc3 100644 --- a/ts/packages/actionGrammar/test/testUtils.ts +++ b/ts/packages/actionGrammar/test/testUtils.ts @@ -3,6 +3,7 @@ import { isDeepStrictEqual } from "node:util"; import { matchGrammar } from "../src/grammarMatcher.js"; +import type { SeparatorMode } from "../src/grammarMatcher.js"; import { matchGrammarCompletion, type GrammarCompletionResult, @@ -145,6 +146,7 @@ function testCompletionDFA( properties: result.properties?.map((p) => ({ match: { actionName: p.actionName }, propertyNames: [p.propertyPath], + separatorMode: "autoSpacePunctuation" as const, })), directionSensitive: false, afterWildcard: "none", @@ -570,13 +572,7 @@ export function expectMetadata( completions?: string[]; groups?: { completions: string[]; separatorMode: string }[]; matchedPrefixLength?: number; - separatorMode?: - | "space" - | "spacePunctuation" - | "optionalSpacePunctuation" - | "optionalSpace" - | "none" - | undefined; + separatorMode?: SeparatorMode | undefined; closedSet?: boolean; directionSensitive?: boolean; afterWildcard?: string; @@ -656,19 +652,19 @@ export function expectMetadata( ), ); - // When the expected property objects omit spacingMode, strip it + // When the expected property objects omit separatorMode, strip it // from actuals so existing tests don't break. Tests that want to - // assert spacingMode include it explicitly. - const checkSpacingMode = (expected.properties ?? []).some( - (p) => "spacingMode" in p, + // assert separatorMode include it explicitly. + const checkSeparatorMode = (expected.properties ?? []).some( + (p) => "separatorMode" in p, ); - if (checkSpacingMode) { + if (checkSeparatorMode) { expect(sortProps(result.properties ?? [])).toEqual( sortProps(expected.properties ?? []), ); } else { const stripped = (result.properties ?? []).map( - ({ spacingMode: _, ...rest }) => rest, + ({ separatorMode: _, ...rest }) => rest, ); expect(sortProps(stripped)).toEqual( sortProps(expected.properties ?? []), diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 9c48af20e..c387d957e 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -88,12 +88,19 @@ export type CommandDescriptors = // overrides. // "none" — no separator at all; menu shown immediately. // Used for [spacing=none] grammars. +// "autoSpacePunctuation" — per-item mode determined by the consumer. +// The consumer inspects the character pair +// (last input char, first completion char) +// and resolves each item to either +// "spacePunctuation" or "optionalSpacePunctuation". +// Used for grammar auto-spacing mode. export type SeparatorMode = | "space" | "spacePunctuation" | "optionalSpacePunctuation" | "optionalSpace" - | "none"; + | "none" + | "autoSpacePunctuation"; // Indicates the user's editing direction, provided by the host. // "forward" — the user is moving ahead (appending characters, diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index caafe9c54..5b8580281 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -335,6 +335,7 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: [p.propertyPath], + separatorMode: "autoSpacePunctuation", }); } } @@ -374,7 +375,7 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: p.propertyNames, - spacingMode: p.spacingMode, + separatorMode: "autoSpacePunctuation", }); } } @@ -453,7 +454,7 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: p.propertyNames, - spacingMode: p.spacingMode, + separatorMode: p.separatorMode, }); } } diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 6fd109e0c..20b6ae096 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -7,7 +7,6 @@ import { SeparatorMode, AfterWildcard, } from "@typeagent/agent-sdk"; -import type { CompiledSpacingMode } from "action-grammar"; import { ExecutableAction, HistoryContext, @@ -81,7 +80,7 @@ export type MatchOptions = { export type CompletionProperty = { actions: ExecutableAction[]; names: string[]; - spacingMode?: CompiledSpacingMode | undefined; // undefined = auto (default) + separatorMode: SeparatorMode; }; export type CompletionResult = { @@ -613,7 +612,7 @@ export class ConstructionCache { ) { continue; } - let mode: SeparatorMode = "optionalSpace"; + let mode: SeparatorMode = "optionalSpacePunctuation"; if ( candidatePrefixLength > 0 && completionText.length > 0 @@ -624,7 +623,7 @@ export class ConstructionCache { ); mode = needsSep ? "spacePunctuation" - : "optionalSpace"; + : "optionalSpacePunctuation"; } addCompletion(completionText, mode); } @@ -659,6 +658,7 @@ export class ConstructionCache { completionProperty.push({ actions: result.match.actions, names: queryPropertyNames, + separatorMode: "autoSpacePunctuation", }); closedSet = false; } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index d6f64c0fe..c312a4031 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -262,7 +262,7 @@ describe("ConstructionCache.completion()", () => { const result = cache.completion("hey! ", defaultOptions); if (result && flatCompletions(result).length > 0) { // ' ' is not a word char → optional - expect(firstSepMode(result!)).toBe("optionalSpace"); + expect(firstSepMode(result!)).toBe("optionalSpacePunctuation"); } }); diff --git a/ts/packages/cache/test/crossGrammarConflict.spec.ts b/ts/packages/cache/test/crossGrammarConflict.spec.ts index 7c931b34f..756092ca2 100644 --- a/ts/packages/cache/test/crossGrammarConflict.spec.ts +++ b/ts/packages/cache/test/crossGrammarConflict.spec.ts @@ -71,9 +71,9 @@ describe("Cross-grammar separator-mode conflict filtering", () => { expect(flatCompletions(result!)).toContain("cd"); // Both grammars survive — closedSet true (same completions). expect(result!.closedSet).toBe(true); - // Two groups: one "none" (from noneSchema), one "spacePunctuation" (from autoSchema). + // Two groups: one "none" (from noneSchema), one "autoSpacePunctuation" (from autoSchema). const modes = result!.groups.map((g) => g.separatorMode).sort(); - expect(modes).toEqual(["none", "spacePunctuation"]); + expect(modes).toEqual(["autoSpacePunctuation", "none"]); }); test("'ab ' trailing separator: both grammars survive with per-group modes", () => { @@ -84,7 +84,7 @@ describe("Cross-grammar separator-mode conflict filtering", () => { expect(result!.closedSet).toBe(true); // Two groups with their respective separator modes. const modes = result!.groups.map((g) => g.separatorMode).sort(); - expect(modes).toEqual(["none", "spacePunctuation"]); + expect(modes).toEqual(["autoSpacePunctuation", "none"]); }); test("afterWildcard preserved when no grammars dropped", () => { diff --git a/ts/packages/cache/test/mergeCompletionResults.spec.ts b/ts/packages/cache/test/mergeCompletionResults.spec.ts index a05b280d5..c058e04ee 100644 --- a/ts/packages/cache/test/mergeCompletionResults.spec.ts +++ b/ts/packages/cache/test/mergeCompletionResults.spec.ts @@ -253,10 +253,12 @@ describe("mergeCompletionResults", () => { const prop1 = { actions: [], names: ["name1"], + separatorMode: "autoSpacePunctuation" as const, }; const prop2 = { actions: [], names: ["name2"], + separatorMode: "autoSpacePunctuation" as const, }; const first: CompletionResult = { groups: [ @@ -278,6 +280,7 @@ describe("mergeCompletionResults", () => { const prop1 = { actions: [], names: ["name1"], + separatorMode: "autoSpacePunctuation" as const, }; const first: CompletionResult = { groups: [ @@ -298,6 +301,7 @@ describe("mergeCompletionResults", () => { const prop2 = { actions: [], names: ["name2"], + separatorMode: "autoSpacePunctuation" as const, }; const first: CompletionResult = { groups: [ diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 9c771454a..6be95a304 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -11,8 +11,6 @@ import { SeparatorMode, TypeAgentAction, } from "@typeagent/agent-sdk"; -import type { CompiledSpacingMode } from "action-grammar"; -import { candidateSeparatorMode, requiresSeparator } from "action-grammar"; import { DeepPartialUndefined } from "@typeagent/common-utils"; import { ActionParamType, @@ -129,9 +127,7 @@ export async function requestCompletion( completionProperty.actions, context, propertyCompletions, - completionProperty.spacingMode, - input, - matchedPrefixLength ?? 0, + completionProperty.separatorMode, ); } } @@ -146,31 +142,12 @@ export async function requestCompletion( }; } -// Compute the SeparatorMode for a completion string given the -// spacing mode, the input text, and the matched prefix length. -function completionSeparatorMode( - completion: string, - spacingMode: CompiledSpacingMode, - input: string, - prefixLength: number, -): SeparatorMode { - if (prefixLength <= 0 || completion.length === 0) { - return spacingMode === "none" ? "none" : "optionalSpace"; - } - const needsSep = - spacingMode !== "none" && - requiresSeparator(input[prefixLength - 1], completion[0], spacingMode); - return candidateSeparatorMode(needsSep, spacingMode); -} - async function collectActionCompletions( properties: string[], partialActions: ExecutableAction[], context: CommandHandlerContext, propertyCompletions: Map, - spacingMode: CompiledSpacingMode, - input: string, - matchedPrefixLength: number, + separatorMode: SeparatorMode, ) { for (const propertyName of properties) { const { action, parameterName } = getPropertyInfo( @@ -190,41 +167,15 @@ async function collectActionCompletions( ); if (paramCompletion !== undefined) { - // Partition completions by their computed separator mode, - // creating one CompletionGroup per (property, mode) pair. - const byMode = new Map(); - for (const c of paramCompletion.completions) { - const mode = completionSeparatorMode( - c, - spacingMode, - input, - matchedPrefixLength, - ); - let bucket = byMode.get(mode); - if (bucket === undefined) { - bucket = []; - byMode.set(mode, bucket); - } - bucket.push(c); - } - - for (const [mode, completions] of byMode) { - // Use a composite key so multiple modes from the same - // property don't overwrite each other. - const key = - byMode.size === 1 - ? propertyName - : `${propertyName}:${mode}`; - propertyCompletions.set(key, { - name: `property ${propertyName}`, - completions, - emojiChar: paramCompletion.emojiChar, - separatorMode: mode, - needQuotes: false, // Request completions are partial, no quotes needed - sorted: true, // REVIEW: assume property completions are already in desired order by the agent. - kind: "entity", - }); - } + propertyCompletions.set(propertyName, { + name: `property ${propertyName}`, + completions: paramCompletion.completions, + emojiChar: paramCompletion.emojiChar, + separatorMode, + needQuotes: false, // Request completions are partial, no quotes needed + sorted: true, // REVIEW: assume property completions are already in desired order by the agent. + kind: "entity", + }); } } } diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 3bae4bf37..07d8c824f 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -8,6 +8,28 @@ import { CompletionGroup, SeparatorMode, } from "@typeagent/agent-sdk"; + +// Inline auto-separator resolution (mirrors action-grammar's +// needsSeparatorInAutoMode). Inlined to avoid importing action-grammar +// into the renderer — that package has Node.js-only modules that the +// Vite browser bundle can't resolve. +const wordBoundaryScriptRe = + /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}|\p{Script=Armenian}|\p{Script=Georgian}|\p{Script=Hangul}|\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Devanagari}|\p{Script=Bengali}|\p{Script=Tamil}|\p{Script=Telugu}|\p{Script=Kannada}|\p{Script=Malayalam}|\p{Script=Gujarati}|\p{Script=Gurmukhi}|\p{Script=Oriya}|\p{Script=Sinhala}|\p{Script=Ethiopic}|\p{Script=Mongolian}/u; +const digitRe = /[0-9]/; + +function needsSeparatorInAutoMode(a: string, b: string): boolean { + if (digitRe.test(a) && digitRe.test(b)) { + return true; + } + const isWordBoundary = (c: string): boolean => { + const code = c.charCodeAt(0); + if (code < 128) { + return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); + } + return wordBoundaryScriptRe.test(c); + }; + return isWordBoundary(a) && isWordBoundary(b); +} import { SearchMenuItem, SearchMenuPosition, @@ -491,7 +513,11 @@ export class PartialCompletionSession { ); this.directionSensitive = result.directionSensitive; - const partitions = toPartitions(result.completions); + const partitions = toPartitions( + result.completions, + input, + result.startIndex, + ); if (partitions.length === 0) { debug( @@ -691,27 +717,64 @@ type ItemPartition = { // Convert backend CompletionGroups into partitions keyed by separatorMode, // preserving group order and sorting within each group. -function toPartitions(groups: CompletionGroup[]): ItemPartition[] { +// +// Groups with separatorMode="autoSpacePunctuation" are resolved per-item: +// each completion is assigned "spacePunctuation" or +// "optionalSpacePunctuation" based on the character pair +// (input[startIndex-1], completion[0]). This preserves the agent's +// original ordering across the resulting partitions via sortIndex. +function toPartitions( + groups: CompletionGroup[], + input: string, + startIndex: number, +): ItemPartition[] { const map = new Map(); let sortIndex = 0; - for (const group of groups) { - const sorted = group.sorted - ? group.completions - : [...group.completions].sort(); - const mode = group.separatorMode; + + function addItem( + mode: SeparatorMode | undefined, + choice: string, + group: CompletionGroup, + ): void { let bucket = map.get(mode); if (bucket === undefined) { bucket = []; map.set(mode, bucket); } - for (const choice of sorted) { - bucket.push({ - matchText: choice, - selectedText: choice, - sortIndex: sortIndex++, - needQuotes: group.needQuotes, - emojiChar: group.emojiChar, - }); + bucket.push({ + matchText: choice, + selectedText: choice, + sortIndex: sortIndex++, + needQuotes: group.needQuotes, + emojiChar: group.emojiChar, + }); + } + + for (const group of groups) { + const sorted = group.sorted + ? group.completions + : [...group.completions].sort(); + if (group.separatorMode === "autoSpacePunctuation") { + // Resolve per-item: inspect character pair to determine + // whether a separator is needed. + for (const choice of sorted) { + const needsSep = + startIndex > 0 && + choice.length > 0 && + needsSeparatorInAutoMode( + input[startIndex - 1], + choice[0], + ); + addItem( + needsSep ? "spacePunctuation" : "optionalSpacePunctuation", + choice, + group, + ); + } + } else { + for (const choice of sorted) { + addItem(group.separatorMode, choice, group); + } } } return Array.from(map, ([mode, items]) => ({ mode, items })).filter( diff --git a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts index b96b1c9d3..7483d17a9 100644 --- a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts +++ b/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts @@ -1300,7 +1300,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => await flush(); // Double space: anchor is "ab " (P=3), rawPrefix=" " (second space). - // separatorMode="optionalSpace" → needsSep=false, but "optionalSpace" + // separatorMode="optionalSpacePunctuation" → needsSep=false, but "optionalSpacePunctuation" // still strips leading whitespace → completionPrefix="". // Empty prefix matches all completions → menu shows. session.update("ab ", getPos); @@ -1339,7 +1339,7 @@ describe("PartialCompletionSession — grammar e2e with mocked entities", () => session.update("ab ", getPos); await flush(); - // At anchor "ab " (P=3), separatorMode="optionalSpace", + // At anchor "ab " (P=3), separatorMode="optionalSpacePunctuation", // rawPrefix="c" → trie prefix "c" → matches "cd". session.update("ab c", getPos); From 2a7a873e6a2d6bf92a7b926d03a87b41680bfc35 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 13:33:50 -0700 Subject: [PATCH 05/10] Review fixes: ResolvedSeparatorMode type, targetLevel undefined, loop guard, docs, import order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ResolvedSeparatorMode (excludes autoSpacePunctuation) for processed partitions; toPartitions resolves undefined→space explicitly - targetLevel/lowestLevelWithItems return undefined when no items exist; callers handle the empty case - Add defensive loop guard (menuSepLevel < 2) in reuseSession D1 WIDEN - Move inline needsSeparatorInAutoMode below imports; add SYNC comments cross-referencing grammarMatcher.ts and partialCompletionSession.ts - Update completion.md: remove stale separatorMode from CommandCompletionResult/CompletionGroups, add separatorMode to CompletionGroup table --- ts/docs/architecture/completion.md | 24 +++---- .../actionGrammar/src/grammarMatcher.ts | 1 + .../renderer/src/partialCompletionSession.ts | 70 +++++++++++-------- 3 files changed, 51 insertions(+), 44 deletions(-) diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index defe06375..87e163a74 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -91,8 +91,7 @@ The return path carries `CommandCompletionResult`: ```typescript { startIndex: number; // where the resolved prefix ends - completions: CompletionGroup[]; - separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optionalSpacePunctuation" | "optionalSpace" | "none" + completions: CompletionGroup[]; // each group carries its own separatorMode closedSet: boolean; // true → list is exhaustive directionSensitive: boolean; // true → completion(input[0..P], backward) ≠ completion(input[0..P], forward) afterWildcard: AfterWildcard; // "none" | "some" | "all" — wildcard boundary ambiguity @@ -280,24 +279,21 @@ Agents implement this optional method to provide domain-specific completions type CompletionGroups = { groups: CompletionGroup[]; matchedPrefixLength?: number; // grammar override for startIndex - separatorMode?: SeparatorMode; closedSet?: boolean; }; ``` Each `CompletionGroup` carries: -| Field | Purpose | -| ------------- | --------------------------------------------------------- | -| `name` | Group label | -| `completions` | String values | -| `needQuotes` | Quote values containing spaces | -| `emojiChar` | Optional icon | -| `sorted` | Whether already sorted | -| `kind` | `"literal"` (grammar tokens) or `"entity"` (agent values) | - -**Helper:** `mergeSeparatorMode(a, b)` resolves conflicts by picking the -strongest requirement. +| Field | Purpose | +| --------------- | ---------------------------------------------------------- | +| `name` | Group label | +| `completions` | String values | +| `needQuotes` | Quote values containing spaces | +| `emojiChar` | Optional icon | +| `sorted` | Whether already sorted | +| `kind` | `"literal"` (grammar tokens) or `"entity"` (agent values) | +| `separatorMode` | What separator is required before this group's completions | --- diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 93eff3239..06237ba13 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -67,6 +67,7 @@ function isWordBoundaryScript(c: string): boolean { } return wordBoundaryScriptRe.test(c); } +// SYNC: partialCompletionSession.ts inlines a copy for the browser bundle. export function needsSeparatorInAutoMode(a: string, b: string): boolean { if (digitRe.test(a) && digitRe.test(b)) { return true; diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 07d8c824f..ec2628145 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -8,11 +8,17 @@ import { CompletionGroup, SeparatorMode, } from "@typeagent/agent-sdk"; +import { + SearchMenuItem, + SearchMenuPosition, +} from "../../preload/electronTypes.js"; +import registerDebug from "debug"; // Inline auto-separator resolution (mirrors action-grammar's // needsSeparatorInAutoMode). Inlined to avoid importing action-grammar // into the renderer — that package has Node.js-only modules that the // Vite browser bundle can't resolve. +// SYNC: keep in sync with grammarMatcher.ts needsSeparatorInAutoMode. const wordBoundaryScriptRe = /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}|\p{Script=Armenian}|\p{Script=Georgian}|\p{Script=Hangul}|\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Devanagari}|\p{Script=Bengali}|\p{Script=Tamil}|\p{Script=Telugu}|\p{Script=Kannada}|\p{Script=Malayalam}|\p{Script=Gujarati}|\p{Script=Gurmukhi}|\p{Script=Oriya}|\p{Script=Sinhala}|\p{Script=Ethiopic}|\p{Script=Mongolian}/u; const digitRe = /[0-9]/; @@ -30,11 +36,6 @@ function needsSeparatorInAutoMode(a: string, b: string): boolean { }; return isWordBoundary(a) && isWordBoundary(b); } -import { - SearchMenuItem, - SearchMenuPosition, -} from "../../preload/electronTypes.js"; -import registerDebug from "debug"; const debug = registerDebug("typeagent:shell:partial"); const debugError = registerDebug("typeagent:shell:partial:error"); @@ -249,7 +250,7 @@ export class PartialCompletionSession { const sepLevel = computeSepLevel(rawPrefix); if (sepLevel !== this.menuSepLevel) { const newLevel = targetLevel(this.levelCounts, sepLevel); - if (newLevel !== this.menuSepLevel) { + if (newLevel !== undefined && newLevel !== this.menuSepLevel) { this.loadLevel(newLevel); this.positionMenu(rawPrefix, getPosition); return; @@ -445,7 +446,9 @@ export class PartialCompletionSession { // ── D. Exhaustion cascade ──────────────────────────────── // [D1] WIDEN — higher level available, reload trie. - if (sepLevel > this.menuSepLevel) { + // Guard: menuSepLevel can only increase (0→1→2) and sepLevel + // is at most 2, so the loop terminates in ≤2 iterations. + if (sepLevel > this.menuSepLevel && this.menuSepLevel < 2) { this.loadLevel((this.menuSepLevel + 1) as SepLevel); debug( `Partial completion widen: menuSepLevel=${this.menuSepLevel}`, @@ -539,9 +542,12 @@ export class PartialCompletionSession { // Pick the best trie level for the caller's input: // highest level ≤ inputSepLevel with items, falling // back to lowestLevelWithItems for skip-ahead. + // When no level has items (shouldn't happen — we + // checked partitions.length > 0), default to level 0. const rawPrefix = input.substring(partial.length); this.loadLevel( - targetLevel(this.levelCounts, computeSepLevel(rawPrefix)), + targetLevel(this.levelCounts, computeSepLevel(rawPrefix)) ?? + 0, ); // If triggered by an explicit close, only reopen when the @@ -584,14 +590,19 @@ export class PartialCompletionSession { // Level 0 (none): No separator. Items needing no separator. // Level 1 (space): Whitespace present. Strip whitespace only. // Level 2 (spacePunc): Whitespace + punctuation. Strip both. -// + +// SeparatorMode after "autoSpacePunctuation" has been resolved per-item +// and undefined has been defaulted to "space". Partitions always use +// this type — no deferred or missing modes at the shell level. +type ResolvedSeparatorMode = Exclude; + // Visibility matrix (non-cumulative, per-level): // -// SeparatorMode Lv0 Lv1 Lv2 +// ResolvedSeparatorMode Lv0 Lv1 Lv2 // "none" ✓ — — // "optionalSpace" ✓ ✓ — // "optionalSpacePunctuation" ✓ ✓ ✓ -// undefined / "space" — ✓ — +// "space" — ✓ — // "spacePunctuation" — ✓ ✓ // // Each level has its own set of items. Widening replaces the trie @@ -612,10 +623,7 @@ function computeSepLevel(rawPrefix: string): SepLevel { // Returns true when a partition's separatorMode belongs to the given // level per the visibility matrix. Non-cumulative. -function isModeAtLevel( - mode: SeparatorMode | undefined, - level: SepLevel, -): boolean { +function isModeAtLevel(mode: ResolvedSeparatorMode, level: SepLevel): boolean { switch (level) { case 0: return ( @@ -625,7 +633,6 @@ function isModeAtLevel( ); case 1: return ( - mode === undefined || mode === "space" || mode === "optionalSpace" || mode === "optionalSpacePunctuation" || @@ -673,21 +680,25 @@ function computeLevelCounts(partitions: ItemPartition[]): LevelCounts { return counts; } -// Returns the lowest SepLevel that has items. Used for skip-ahead: -// when no items exist at level 0, jump to the first level that does. -function lowestLevelWithItems(counts: LevelCounts): SepLevel { +// Returns the lowest SepLevel that has items, or undefined when all +// counts are zero. +function lowestLevelWithItems(counts: LevelCounts): SepLevel | undefined { for (let level = 0; level <= 2; level++) { if (counts[level] > 0) { return level as SepLevel; } } - return 0; + return undefined; } // Returns the highest SepLevel ≤ maxLevel that has items, falling back // to lowestLevelWithItems when nothing exists at or below maxLevel -// (e.g. entities that only appear at level 1+). -function targetLevel(counts: LevelCounts, maxLevel: SepLevel): SepLevel { +// (e.g. entities that only appear at level 1+). Returns undefined when +// no level has items at all. +function targetLevel( + counts: LevelCounts, + maxLevel: SepLevel, +): SepLevel | undefined { for (let l = maxLevel; l > 0; l--) { if (counts[l] > 0) { return l as SepLevel; @@ -711,7 +722,7 @@ function stripAtLevel(rawPrefix: string, level: SepLevel): string { // An items-by-mode bucket. Each partition holds the items from one or more // CompletionGroups that share the same separatorMode. type ItemPartition = { - mode: SeparatorMode | undefined; + mode: ResolvedSeparatorMode; items: SearchMenuItem[]; }; @@ -728,11 +739,11 @@ function toPartitions( input: string, startIndex: number, ): ItemPartition[] { - const map = new Map(); + const map = new Map(); let sortIndex = 0; function addItem( - mode: SeparatorMode | undefined, + mode: ResolvedSeparatorMode, choice: string, group: CompletionGroup, ): void { @@ -761,10 +772,7 @@ function toPartitions( const needsSep = startIndex > 0 && choice.length > 0 && - needsSeparatorInAutoMode( - input[startIndex - 1], - choice[0], - ); + needsSeparatorInAutoMode(input[startIndex - 1], choice[0]); addItem( needsSep ? "spacePunctuation" : "optionalSpacePunctuation", choice, @@ -772,8 +780,10 @@ function toPartitions( ); } } else { + // Resolve undefined → "space" (the default separatorMode). + const mode: ResolvedSeparatorMode = group.separatorMode ?? "space"; for (const choice of sorted) { - addItem(group.separatorMode, choice, group); + addItem(mode, choice, group); } } } From 9f69fd0b8a7a445324de43c54259690691fe2211 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 14:28:44 -0700 Subject: [PATCH 06/10] Defer separator mode resolution from cache/grammar to dispatcher/shell Remove candidateSeparatorMode() from grammarMatcher and the per-character needsSeparatorInAutoMode logic from constructionCache completion. Both now emit 'autoSpacePunctuation' uniformly, deferring resolution to consumers with richer input context (dispatcher and shell). In the dispatcher, resolve autoSpacePunctuation/spacePunctuation/ optionalSpacePunctuation based on target.separatorMode when grammar matchedPrefixLength is zero or undefined. Also change subcommand completion separatorMode from 'none' to 'optionalSpace' for consistency. --- .../actionGrammar/src/grammarMatcher.ts | 26 ------------------- .../src/constructions/constructionCache.ts | 20 ++------------ ts/packages/cache/test/completion.spec.ts | 26 +++++++++---------- .../dispatcher/src/command/completion.ts | 19 +++++++++++++- 4 files changed, 33 insertions(+), 58 deletions(-) diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 06237ba13..427e3ea38 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -96,32 +96,6 @@ export function requiresSeparator( } } -// Convert a per-candidate (needsSep, spacingMode) pair into a -// SeparatorMode value. When needsSep is true (separator required), -// the grammar always uses spacePunctuation separators. -// When needsSep is false: -// "none" spacingMode → "none" -// "optional" spacingMode → "optionalSpacePunctuation" (explicit -// [spacing=optional] annotation; separator not required, but -// when present may be whitespace or punctuation) -// auto (undefined) → "optionalSpace" (CJK/digit/mixed — separator -// not required; when present only whitespace is meaningful) -export function candidateSeparatorMode( - needsSep: boolean, - spacingMode: CompiledSpacingMode, -): SeparatorMode { - if (needsSep) { - return "spacePunctuation"; - } - if (spacingMode === "none") { - return "none"; - } - if (spacingMode === "optional") { - return "optionalSpacePunctuation"; - } - return "optionalSpace"; -} - export function isBoundarySatisfied( request: string, index: number, diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 20b6ae096..a869b64e2 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -30,10 +30,7 @@ import { ConstructionCacheJSON, constructionCacheJSONVersion, } from "./constructionJSONTypes.js"; -import { - getLanguageTools, - needsSeparatorInAutoMode, -} from "../utils/language.js"; +import { getLanguageTools } from "../utils/language.js"; const debugConst = registerDebug("typeagent:const"); const debugConstMatchStat = registerDebug("typeagent:const:match:stat"); const debugCompletion = registerDebug("typeagent:const:completion"); @@ -612,20 +609,7 @@ export class ConstructionCache { ) { continue; } - let mode: SeparatorMode = "optionalSpacePunctuation"; - if ( - candidatePrefixLength > 0 && - completionText.length > 0 - ) { - const needsSep = needsSeparatorInAutoMode( - input[candidatePrefixLength - 1], - completionText[0], - ); - mode = needsSep - ? "spacePunctuation" - : "optionalSpacePunctuation"; - } - addCompletion(completionText, mode); + addCompletion(completionText, "autoSpacePunctuation"); } } } diff --git a/ts/packages/cache/test/completion.spec.ts b/ts/packages/cache/test/completion.spec.ts index c312a4031..c6ff34108 100644 --- a/ts/packages/cache/test/completion.spec.ts +++ b/ts/packages/cache/test/completion.spec.ts @@ -218,9 +218,9 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("play ", defaultOptions); expect(result).toBeDefined(); - // The matcher consumes "play" (4 chars). With per-group - // separator modes, the grammar's native mode is preserved. - expect(firstSepMode(result!)).toBe("spacePunctuation"); + // The matcher consumes "play" (4 chars). Construction cache + // defers per-item separator resolution to the shell. + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); }); it("returns spacePunctuation between adjacent word characters", () => { @@ -243,8 +243,8 @@ describe("ConstructionCache.completion()", () => { flatCompletions(result!).length > 0 && result!.matchedPrefixLength === 4 ) { - // 'y' and 's' are both Latin word-boundary — needs separator - expect(firstSepMode(result!)).toBe("spacePunctuation"); + // Construction cache defers resolution → autoSpacePunctuation + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); } }); @@ -261,8 +261,8 @@ describe("ConstructionCache.completion()", () => { const cache = makeCache([c]); const result = cache.completion("hey! ", defaultOptions); if (result && flatCompletions(result).length > 0) { - // ' ' is not a word char → optional - expect(firstSepMode(result!)).toBe("optionalSpacePunctuation"); + // Construction cache defers resolution → autoSpacePunctuation + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); } }); @@ -282,8 +282,8 @@ describe("ConstructionCache.completion()", () => { flatCompletions(result).length > 0 && result.matchedPrefixLength === 5 ) { - // '3' and '4' are digits → needs separator - expect(firstSepMode(result!)).toBe("spacePunctuation"); + // Construction cache defers resolution → autoSpacePunctuation + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); } }); }); @@ -457,7 +457,7 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); expect(flatCompletions(result!)).toEqual(["song"]); expect(result!.matchedPrefixLength).toBe(4); - expect(firstSepMode(result!)).toBe("spacePunctuation"); + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); expect(result!.closedSet).toBe(true); }); @@ -466,10 +466,10 @@ describe("ConstructionCache.completion()", () => { expect(result).toBeDefined(); expect(flatCompletions(result!)).toEqual(["song"]); // Trailing space consumed → matchedPrefixLength advances to 5. - // With per-group separator modes, the grammar's native mode - // is preserved (no advance-1 demotion). + // Construction cache defers per-item separator resolution + // to the shell. expect(result!.matchedPrefixLength).toBe(5); - expect(firstSepMode(result!)).toBe("spacePunctuation"); + expect(firstSepMode(result!)).toBe("autoSpacePunctuation"); }); it("prefix 'play s' — partial intra-part on second part, returns completions", () => { diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 53f81ef4c..b7ad2ea9d 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -468,6 +468,22 @@ async function getCommandParameterCompletion( if (groupPrefixLength !== undefined && groupPrefixLength !== 0) { startIndex = target.startIndex + groupPrefixLength; completions.length = 0; // grammar overrides built-in completions + } else { + // Override separatorMode if matchedPrefixLength is undefined or zero + for (const group of agentResult.groups) { + if ( + group.separatorMode === "autoSpacePunctuation" || + group.separatorMode === "spacePunctuation" || + group.separatorMode === "optionalSpacePunctuation" + ) { + group.separatorMode = + target.separatorMode === "optionalSpace" + ? "optionalSpacePunctuation" + : "spacePunctuation"; + } else { + group.separatorMode = target.separatorMode; + } + } } completions.push(...agentResult.groups); agentInvoked = true; @@ -541,6 +557,7 @@ async function completeDescriptor( completions.push({ name: "Subcommands", completions: Object.keys(table!.commands), + separatorMode: "optionalSpace", }); } @@ -691,7 +708,7 @@ export async function getCommandCompletion( completions.push({ name: "Subcommands", completions: Object.keys(table!.commands), - separatorMode: "none", + separatorMode: "optionalSpace", }); directionSensitive = true; // closedSet stays true: subcommand names are exhaustive. From e6130a0c14ed44e44d9a46380fc7d36569d8cf67 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 15:28:21 -0700 Subject: [PATCH 07/10] Completion: per-group separator modes instead of conflict filtering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the merged separator-mode model with per-group separator modes. Each CompletionGroup now carries its own separatorMode; the shell's SepLevel model shows/hides groups based on trailing separator state instead of the grammar layer filtering conflicting candidates. - Refactor hasWhitespaceBefore() to inferSeparatorMode(), handling index=0 and returning SeparatorMode directly - NFA completions use autoSpacePunctuation (consumer resolves per-item) - Remove conflict-filtering docs; update invariant #13 and separator mode table - Simplify toPartitions() — empty partitions are harmless - Add debugCompletion channel to grammarStore --- ts/docs/architecture/actionGrammar.md | 83 +++++-------------- ts/docs/architecture/completion.md | 38 ++++----- .../actionGrammar/src/nfaCompletion.ts | 5 +- ts/packages/agentSdk/src/command.ts | 14 ++-- ts/packages/cache/src/cache/grammarStore.ts | 12 +++ .../dispatcher/src/command/completion.ts | 46 +++++----- .../dispatcher/test/completion.spec.ts | 30 +++---- .../renderer/src/partialCompletionSession.ts | 6 +- 8 files changed, 103 insertions(+), 131 deletions(-) diff --git a/ts/docs/architecture/actionGrammar.md b/ts/docs/architecture/actionGrammar.md index cd87b31d4..1c266f0c3 100644 --- a/ts/docs/architecture/actionGrammar.md +++ b/ts/docs/architecture/actionGrammar.md @@ -853,70 +853,25 @@ flow through the cache, dispatcher, and shell layers, and `completion.md` § Invariants for the full catalog of correctness invariants, their user-visible impact, and which tests verify them. -### Separator-mode conflict filtering - -When fixed candidates at the same `maxPrefixLength` come from rules -with different `spacingMode` values — for example, a `[spacing=none]` -rule and a default-spacing rule that both match the same prefix — the -single merged `separatorMode` cannot correctly represent all of them. -A `"none"` candidate rejects any trailing separator, while a -`"spacePunctuation"` candidate requires one. The conflict-filtering -logic in `filterSepConflicts()` (called from `finalizeCandidates()`) resolves this: - -Three-way compatibility: - -| Trailing sep? | `spacePunctuation` | `optional` | `none` | -| ------------- | ------------------ | ---------- | ------ | -| No | drop | keep | keep | -| Yes | keep | keep | drop | - -1. **Detect:** Compute each candidate's individual `SeparatorMode` via - `computeCandidateSeparatorMode()`. A conflict exists when both - `isRequiringSepMode()` candidates (need separator) and `"none"` - candidates (reject separator) are present. - -2. **Filter by trailing separator state:** Inspect `input[maxPrefixLength]`: - - - Trailing separator present → drop `"none"` candidates (they would - reject the existing separator). - - No trailing separator → drop requiring candidates (a separator - would need to be inserted). - -3. **Advance P by one character:** When trailing separator is present - and candidates were dropped, advance `maxPrefixLength` by exactly - one character (not past all consecutive separators). This ensures - backspace triggers a re-fetch (the anchor diverges). Override - `separatorMode` to `"optionalSpace"` since the separator is already - consumed into P. - - Advance-1 is preferred over advance-all because: - - - Each backspace in the separator run produces a distinct anchor, - giving the shell a re-fetch opportunity at every keystroke. - - With advance-all, deleting the _last_ separator in a multi- - separator run is the only keystroke that triggers a re-fetch; - intermediate deletes are invisible to the completion system. - - Advance-1 matches the shell's `separatorMode="optionalSpace"` contract: - the session sees one consumed separator and treats the rest as - ordinary prefix text. The shell strips leading whitespace for - `"optionalSpace"` mode (just as it does for requiring modes), so extra - separators do not pollute the trie — the menu stays visible with - an empty or narrowed prefix. - - The re-fetch cost is negligible — the grammar matcher runs in - sub-millisecond time. - -4. **Force `closedSet=false`:** When candidates are dropped, the - completion list is no longer exhaustive. The shell must re-fetch when - the separator state changes (typing or deleting a space). - -5. **Force `afterWildcard` `"all"` → `"some"`:** When candidates are - dropped and all surviving candidates are after a wildcard, - `afterWildcard` is downgraded from `"all"` to `"some"` to prevent - the shell from using the "slide" `noMatchPolicy` (which would - suppress the re-fetch). - -The same conflict-detection logic is applied cross-grammar in -`grammarStore.ts` after collecting per-grammar results. +### Per-group separator modes (replaces conflict filtering) + +When candidates at the same `maxPrefixLength` come from rules with +different `spacingMode` values — for example, a `[spacing=none]` +rule and a default-spacing rule that both match the same prefix — +each candidate's `separatorMode` is recorded in its own +`GrammarCompletionGroup`. The grammar matcher no longer merges +separator modes or filters conflicting candidates; instead, each +group carries its own `separatorMode` and the shell's **SepLevel** +model (see `partialCompletionSession.ts`) shows or hides groups +based on the user's trailing separator state. + +This means: + +- No candidates are dropped at the grammar level. +- `closedSet` is not forced to `false` by separator conflicts. +- `afterWildcard` is not downgraded by separator conflicts. +- Cross-grammar conflict filtering in `grammarStore.ts` is no longer + needed — each grammar's groups are passed through directly. ### Direction asymmetry: why only Category 3b needs shadow candidates diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 87e163a74..dcc83c6df 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -457,12 +457,14 @@ Trie-backed prefix filtering: Controls what character is required between the matched prefix and completion text. -| Value | Meaning | Use case | -| -------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `"space"` | Whitespace required | Commands, flags, agent names | -| `"spacePunctuation"` | Whitespace or Unicode punctuation | Latin-script grammar completions | -| `"optionalSpace"` | Separator accepted but not required | CJK / mixed-script grammars; also digit–Latin boundaries (digits are Unicode script "Common", not "Latin", so a transition like `"0"→"i"` is a script change that does not require a separator) | -| `"none"` | No separator | Grammar rules annotated with `[spacing=none]`. At the top level, no leading or trailing whitespace is consumed. For nested rules, the parent rule's spacing controls the boundaries around the child; the child's `"none"` only affects its own internal token boundaries. | +| Value | Meaning | Use case | +| ---------------------------- | ------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `"space"` | Whitespace required | Commands, flags, agent names | +| `"spacePunctuation"` | Whitespace or Unicode punctuation | Latin-script grammar completions | +| `"optionalSpacePunctuation"` | Separator accepted but not required; when present, whitespace or Unicode punctuation | Grammar rules annotated with `[spacing=optional]`; also the resolved form of `"autoSpacePunctuation"` when no separator is needed between the adjacent characters | +| `"optionalSpace"` | Separator accepted but not required; when present, only whitespace | Command/flag-level completions where trailing whitespace was already consumed into `startIndex`; subcommand and agent-name completions | +| `"none"` | No separator | Grammar rules annotated with `[spacing=none]`. At the top level, no leading or trailing whitespace is consumed. For nested rules, the parent rule's spacing controls the boundaries around the child; the child's `"none"` only affects its own internal token boundaries. | +| `"autoSpacePunctuation"` | Per-item; resolved by the consumer | Grammar auto-spacing mode (default). The consumer inspects the character pair (last input char, first completion char) and resolves each item to `"spacePunctuation"` or `"optionalSpacePunctuation"`. The shell resolves this in `toPartitions()`. | See `actionGrammar.md` Spacing modes for how the grammar matcher determines `separatorMode` from spacing annotations. The matcher @@ -837,10 +839,13 @@ Closed only if ALL sources are closed. _Impact:_ Premature "accept" when one source is open — user misses completions from that source. -**#13 — `separatorMode`: strongest requirement wins.** -`"space"` > `"spacePunctuation"` > `"optionalSpacePunctuation"` > `"optionalSpace"` > `"none"`. -_Impact:_ Fused display if a weak mode wins over a strong one, or -unnecessary separation if the reverse. +**#13 — `separatorMode`: per-group, no cross-group merging.** +Each `CompletionGroup` carries its own `separatorMode`. The shell's +SepLevel model (see `partialCompletionSession.ts`) partitions groups +by mode and shows/hides them based on the user's trailing separator +state. No merging or priority ordering is needed. +_Impact:_ Fused display if a group's mode is wrong, or unnecessary +separation if a wrong mode is applied. **#14 — `directionSensitive`: OR-merge.** Sensitive if ANY source is sensitive. @@ -861,19 +866,12 @@ definite completions to slide — `"some"` triggers re-fetch instead. handles this correctly. The two-pass invariant check skips this case (when `forwardAtP.matchedPrefixLength < P`). -### Direction asymmetry and separator-mode conflicts +### Direction asymmetry -Two related mechanisms protect the invariants when rules with different +One mechanism protects the invariants when rules with different spacing modes compete for the same `maxPrefixLength`: -1. **Separator-mode conflict filtering** (`filterSepConflicts` in - `grammarCompletion.ts`, post-loop in `grammarStore.ts`): when - `"none"` and requiring-separator candidates coexist, filters by - trailing separator state, advances P, and forces `closedSet=false`. - Protects invariant #9 (`separatorMode="none"` for `[spacing=none]` - rules) and #13 (strongest separator requirement wins at merge). - -2. **Deferred shadow candidates** (`DeferredShadowCandidate` in +1. **Deferred shadow candidates** (`DeferredShadowCandidate` in `grammarCompletion.ts`): when Category 3b backward backs up past the forward position, a shadow candidate is collected and flushed after Phase 2. Protects invariant #3 (truncated-forward idempotency), diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index 25454947e..99a26a038 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -291,7 +291,10 @@ export function computeNFACompletions( groups: [ { completions: uniqueCompletions, - separatorMode: "space", + // NFA does not track per-rule spacing modes, so use + // auto mode — the consumer resolves per-item based on + // the character pair (last input char, first completion char). + separatorMode: "autoSpacePunctuation", }, ], directionSensitive: false, diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index c387d957e..a9089bd58 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -78,14 +78,18 @@ export type CommandDescriptors = // Latin-script completions. // "optionalSpacePunctuation" — separator accepted but not required; // when present, both whitespace and Unicode -// punctuation are valid separators. Used by +// punctuation are valid separators. Produced by // the grammar matcher for [spacing=optional] -// annotated rules. +// annotated rules, and as the resolved form of +// "autoSpacePunctuation" when no separator is +// needed between the adjacent characters. // "optionalSpace" — separator accepted but not required; when // present, only whitespace is treated as a -// separator. Used for CJK / mixed-script -// grammar completions and consumed-separator -// overrides. +// separator. Used at the command/flag level +// when trailing whitespace was already consumed +// into startIndex (no additional separator +// needed), and for subcommand/agent-name +// completions. // "none" — no separator at all; menu shown immediately. // Used for [spacing=none] grammars. // "autoSpacePunctuation" — per-item mode determined by the consumer. diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index 5b8580281..060637e7d 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -19,6 +19,9 @@ import { } from "action-grammar"; const debug = registerDebug("typeagent:cache:grammarStore"); +const debugCompletion = registerDebug( + "typeagent:cache:grammarStore:completion", +); import { CompletionDirection, CompletionGroup, @@ -303,6 +306,7 @@ export class GrammarStoreImpl implements GrammarStore { if (filter && !filter.has(name)) { continue; } + debugCompletion(`Processing grammar: ${name}`); if (this.useDFA && entry.dfa) { // DFA-based completions const tokens = input @@ -335,6 +339,12 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: [p.propertyPath], + // Property completions represent free-form entity + // slots — the actual values come from agents at + // runtime, so the grammar cannot know the first + // character. "autoSpacePunctuation" defers + // resolution to the shell, which inspects the + // character pair per item. separatorMode: "autoSpacePunctuation", }); } @@ -375,6 +385,8 @@ export class GrammarStoreImpl implements GrammarStore { ), ], names: p.propertyNames, + // See DFA property comment above — auto + // mode for the same reason. separatorMode: "autoSpacePunctuation", }); } diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index b7ad2ea9d..502ea4746 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -76,14 +76,17 @@ function detectPendingFlag( }; } -// True when text[0..index) ends with whitespace — i.e., the user -// has typed trailing whitespace after the last token. Trailing -// whitespace acts as a commit signal: the token before it is -// considered committed and the whitespace itself is consumed, so -// startIndex should include it and separatorMode should be -// "optionalSpace" (no additional separator needed). -function hasWhitespaceBefore(text: string, index: number): boolean { - return index > 0 && /\s/.test(text[index - 1]); +// Returns the separatorMode implied by the position in the input. +// When the position is 0 (beginning of input) or preceded by +// whitespace, the separator is already satisfied → "optionalSpace". +// Otherwise returns undefined (defaults to "space" per CompletionGroup contract). +function inferSeparatorMode( + text: string, + index: number, +): SeparatorMode | undefined { + return index === 0 || /\s/.test(text[index - 1]) + ? "optionalSpace" + : undefined; } // True if surrounded by quotes at both ends (matching single or double quotes). @@ -224,9 +227,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: hasWhitespaceBefore(input, remainderIndex) - ? "optionalSpace" - : undefined, + separatorMode: inferSeparatorMode(input, remainderIndex), directionSensitive: false, }; } @@ -257,9 +258,7 @@ function resolveCompletionTarget( isPartialValue: true, includeFlags: false, booleanFlagName: undefined, - separatorMode: hasWhitespaceBefore(input, startIndex) - ? "optionalSpace" - : undefined, + separatorMode: inferSeparatorMode(input, startIndex), directionSensitive: false, }; } @@ -275,11 +274,11 @@ function resolveCompletionTarget( // Trailing whitespace commits the flag — direction no longer matters. // When the user typed "--level " (with whitespace), they've moved on; // fall through to 2b for value completions regardless of direction. - const trailingWhitespace = hasWhitespaceBefore(input, remainderIndex); + const trailingSepMode = inferSeparatorMode(input, remainderIndex); if ( pendingFlag !== undefined && direction === "backward" && - !trailingWhitespace + trailingSepMode === undefined ) { const flagToken = tokens[tokens.length - 1]; const flagTokenStart = remainderIndex - flagToken.length; @@ -289,9 +288,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: hasWhitespaceBefore(input, flagTokenStart) - ? "optionalSpace" - : undefined, + separatorMode: inferSeparatorMode(input, flagTokenStart), directionSensitive: true, }; } @@ -310,8 +307,8 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: false, booleanFlagName: undefined, - separatorMode: trailingWhitespace ? "optionalSpace" : undefined, - directionSensitive: !trailingWhitespace, + separatorMode: trailingSepMode, + directionSensitive: trailingSepMode === undefined, }; } return { @@ -320,7 +317,7 @@ function resolveCompletionTarget( isPartialValue: false, includeFlags: true, booleanFlagName, - separatorMode: trailingWhitespace ? "optionalSpace" : undefined, + separatorMode: trailingSepMode, directionSensitive: false, }; } @@ -469,7 +466,10 @@ async function getCommandParameterCompletion( startIndex = target.startIndex + groupPrefixLength; completions.length = 0; // grammar overrides built-in completions } else { - // Override separatorMode if matchedPrefixLength is undefined or zero + // Override separatorMode if matchedPrefixLength is undefined or zero. + // Intentionally mutates the agent's group objects in-place — the + // groups are single-use results from the agent callback and are + // not retained by the agent. for (const group of agentResult.groups) { if ( group.separatorMode === "autoSpacePunctuation" || diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index 5ade4a12e..b13e4edc5 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -800,8 +800,9 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // "run" is the default subcommand, so subcommand alternatives - // are included. separatorMode defaults to undefined ("space"). - expect(result!.completions[0].separatorMode).toBeUndefined(); + // are included. Per-group separatorMode is "optionalSpace" + // because the trailing whitespace is already consumed. + expect(result!.completions[0].separatorMode).toBe("optionalSpace"); // startIndex includes trailing whitespace. expect(result!.startIndex).toBe(10); }); @@ -813,7 +814,10 @@ describe("Command Completion - startIndex", () => { context, ); expect(result).toBeDefined(); - expect(result!.completions[0].separatorMode).toBeUndefined(); + // Subcommand group gets "optionalSpace" — no explicit + // subcommand was typed, alternatives offered at the agent + // boundary. + expect(result!.completions[0].separatorMode).toBe("optionalSpace"); expect(result!.startIndex).toBe(9); // Default subcommand has agent completions → not exhaustive. expect(result!.closedSet).toBe(false); @@ -835,20 +839,19 @@ describe("Command Completion - startIndex", () => { expect(result!.closedSet).toBe(true); }); - it("does not set separatorMode for parameter completions only", async () => { + it("returns optionalSpace separatorMode for parameter completions after space", async () => { const result = await getCommandCompletion( "@comptest run bu", "forward", context, ); expect(result).toBeDefined(); - // Partial parameter token — only parameter completions returned, - // no subcommand group. Each group carries its own separatorMode - // (undefined means "space" — the documented default). - expect(result!.completions[0].separatorMode).toBeUndefined(); + // Partial parameter token — the space before "bu" was already + // consumed, so the group's separatorMode is "optionalSpace". + expect(result!.completions[0].separatorMode).toBe("optionalSpace"); }); - it("returns no separatorMode for partial unmatched token consumed as param", async () => { + it("returns optionalSpace separatorMode for partial unmatched token consumed as param", async () => { const result = await getCommandCompletion( "@comptest ne", "forward", @@ -856,15 +859,14 @@ describe("Command Completion - startIndex", () => { ); expect(result).toBeDefined(); // "ne" is fully consumed as the "task" arg by parameter - // parsing. No trailing space. startIndex = 10 - // (after "@comptest "), which is ≤ commandConsumedLength - // (10), so sibling subcommands are included (separatorMode - // defaults to undefined/"space"). + // parsing. startIndex = 10 (after "@comptest "), which is ≤ + // commandConsumedLength (10), so sibling subcommands are + // included with per-group separatorMode. const subcommands = result!.completions.find( (g) => g.name === "Subcommands", ); expect(subcommands).toBeDefined(); - expect(subcommands!.separatorMode).toBeUndefined(); + expect(subcommands!.separatorMode).toBe("optionalSpace"); expect(result!.startIndex).toBe(10); }); }); diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index ec2628145..89f7213ec 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -14,7 +14,7 @@ import { } from "../../preload/electronTypes.js"; import registerDebug from "debug"; -// Inline auto-separator resolution (mirrors action-grammar's +// Inline auto-separator resolution (copy of action-grammar's // needsSeparatorInAutoMode). Inlined to avoid importing action-grammar // into the renderer — that package has Node.js-only modules that the // Vite browser bundle can't resolve. @@ -787,7 +787,5 @@ function toPartitions( } } } - return Array.from(map, ([mode, items]) => ({ mode, items })).filter( - (p) => p.items.length > 0, - ); + return Array.from(map, ([mode, items]) => ({ mode, items })); } From 4595e16b1df6f28628c79bff03fc4ca140a08c0f Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 16:31:23 -0700 Subject: [PATCH 08/10] Test coverage for per-group separatorMode; re-export needsSeparatorInAutoMode Fill testing gaps for the per-group separator mode pipeline: - Dispatcher: separatorMode override logic when matchedPrefixLength=0, inferSeparatorMode at position 0, new automodetest agent fixture - Shell: autoSpacePunctuation resolution (Latin/CJK/digit pairs), toPartitions multi-group bucketing across SepLevels - Cache: cross-layer separatorMode preservation through merge (all 6 modes) - ActionGrammar: spacingModeToSeparatorMode unit tests Re-export needsSeparatorInAutoMode from action-grammar through agent-dispatcher/helpers/completion so the shell renderer imports the canonical implementation instead of maintaining an inlined copy. - action-grammar: add ./completion subpath export (lightweight, no Node deps) - agent-dispatcher: add ./helpers/completion re-export - shell: replace inlined copy with import from dispatcher helper --- ts/packages/actionGrammar/package.json | 1 + ts/packages/actionGrammar/src/completion.ts | 9 + .../actionGrammar/src/grammarMatcher.ts | 1 - .../test/spacingModeToSeparatorMode.spec.ts | 47 +++ .../cache/test/mergeCompletionResults.spec.ts | 185 +++++++++ .../dispatcher/dispatcher/package.json | 1 + .../dispatcher/src/helpers/completion.ts | 9 + .../dispatcher/test/completion.spec.ts | 359 ++++++++++++++++++ .../renderer/src/partialCompletionSession.ts | 24 +- .../partialCompletion/separatorMode.spec.ts | 349 +++++++++++++++++ 10 files changed, 961 insertions(+), 24 deletions(-) create mode 100644 ts/packages/actionGrammar/src/completion.ts create mode 100644 ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts create mode 100644 ts/packages/dispatcher/dispatcher/src/helpers/completion.ts diff --git a/ts/packages/actionGrammar/package.json b/ts/packages/actionGrammar/package.json index 0bf373848..c26565ec8 100644 --- a/ts/packages/actionGrammar/package.json +++ b/ts/packages/actionGrammar/package.json @@ -14,6 +14,7 @@ "type": "module", "exports": { ".": "./dist/index.js", + "./completion": "./dist/completion.js", "./rules": "./dist/indexRules.js", "./generation": "./dist/generation/index.js" }, diff --git a/ts/packages/actionGrammar/src/completion.ts b/ts/packages/actionGrammar/src/completion.ts new file mode 100644 index 000000000..c5b30c552 --- /dev/null +++ b/ts/packages/actionGrammar/src/completion.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Lightweight subpath export for the separator utility function. +// Isolated from the full action-grammar barrel to avoid pulling in +// Node.js-only modules (grammarLoader, grammarCompiler, etc.) when +// imported by browser bundles. + +export { needsSeparatorInAutoMode } from "./grammarMatcher.js"; diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 427e3ea38..48e56cb15 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -67,7 +67,6 @@ function isWordBoundaryScript(c: string): boolean { } return wordBoundaryScriptRe.test(c); } -// SYNC: partialCompletionSession.ts inlines a copy for the browser bundle. export function needsSeparatorInAutoMode(a: string, b: string): boolean { if (digitRe.test(a) && digitRe.test(b)) { return true; diff --git a/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts b/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts new file mode 100644 index 000000000..6e60e6fb8 --- /dev/null +++ b/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Gap 6: Direct unit tests for spacingModeToSeparatorMode, ensuring each +// CompiledSpacingMode value maps to the correct SeparatorMode. + +import { spacingModeToSeparatorMode } from "../src/grammarCompletion.js"; +import type { CompiledSpacingMode } from "../src/grammarTypes.js"; + +describe("spacingModeToSeparatorMode", () => { + it('maps "required" → "spacePunctuation"', () => { + expect(spacingModeToSeparatorMode("required")).toBe("spacePunctuation"); + }); + + it('maps "optional" → "optionalSpacePunctuation"', () => { + expect(spacingModeToSeparatorMode("optional")).toBe( + "optionalSpacePunctuation", + ); + }); + + it('maps "none" → "none"', () => { + expect(spacingModeToSeparatorMode("none")).toBe("none"); + }); + + it('maps undefined (auto) → "autoSpacePunctuation"', () => { + expect( + spacingModeToSeparatorMode( + undefined as unknown as CompiledSpacingMode, + ), + ).toBe("autoSpacePunctuation"); + }); + + it("covers all CompiledSpacingMode values exhaustively", () => { + // Ensure the function handles every possible input without throwing. + const modes: CompiledSpacingMode[] = [ + "required", + "optional", + "none", + undefined as unknown as CompiledSpacingMode, + ]; + for (const mode of modes) { + const result = spacingModeToSeparatorMode(mode); + expect(typeof result).toBe("string"); + expect(result.length).toBeGreaterThan(0); + } + }); +}); diff --git a/ts/packages/cache/test/mergeCompletionResults.spec.ts b/ts/packages/cache/test/mergeCompletionResults.spec.ts index c058e04ee..120d47a4a 100644 --- a/ts/packages/cache/test/mergeCompletionResults.spec.ts +++ b/ts/packages/cache/test/mergeCompletionResults.spec.ts @@ -723,4 +723,189 @@ describe("mergeCompletionResults", () => { expect(result.matchedPrefixLength).toBe(17); }); }); + + // ── Gap 7: cross-layer per-group separatorMode preservation ────── + + describe("per-group separatorMode preservation through merge", () => { + it("preserves different separatorMode values across merged groups", () => { + const first: CompletionResult = { + groups: [ + { + name: "grammar-keywords", + completions: ["play", "stop"], + separatorMode: "spacePunctuation", + }, + ], + matchedPrefixLength: 5, + }; + const second: CompletionResult = { + groups: [ + { + name: "grammar-entities", + completions: ["rock", "jazz"], + separatorMode: "autoSpacePunctuation", + }, + ], + matchedPrefixLength: 5, + }; + const result = mergeCompletionResults(first, second, Infinity)!; + expect(result.groups).toHaveLength(2); + const kw = result.groups.find((g) => g.name === "grammar-keywords"); + const ent = result.groups.find( + (g) => g.name === "grammar-entities", + ); + expect(kw).toBeDefined(); + expect(ent).toBeDefined(); + expect(kw!.separatorMode).toBe("spacePunctuation"); + expect(ent!.separatorMode).toBe("autoSpacePunctuation"); + }); + + it("keeps optionalSpacePunctuation and none groups intact when merging", () => { + const first: CompletionResult = { + groups: [ + { + name: "optional-group", + completions: ["タワー"], + separatorMode: "optionalSpacePunctuation", + }, + ], + matchedPrefixLength: 3, + }; + const second: CompletionResult = { + groups: [ + { + name: "none-group", + completions: ["suffix"], + separatorMode: "none", + }, + ], + matchedPrefixLength: 3, + }; + const result = mergeCompletionResults(first, second, Infinity)!; + expect(result.groups).toHaveLength(2); + expect( + result.groups.find((g) => g.name === "optional-group")! + .separatorMode, + ).toBe("optionalSpacePunctuation"); + expect( + result.groups.find((g) => g.name === "none-group")! + .separatorMode, + ).toBe("none"); + }); + + it("discards shorter-prefix groups while preserving winner's per-group modes", () => { + const shorter: CompletionResult = { + groups: [ + { + name: "short", + completions: ["a"], + separatorMode: "none", + }, + ], + matchedPrefixLength: 3, + }; + const longer: CompletionResult = { + groups: [ + { + name: "long-kw", + completions: ["b"], + separatorMode: "spacePunctuation", + }, + { + name: "long-ent", + completions: ["c"], + separatorMode: "autoSpacePunctuation", + }, + ], + matchedPrefixLength: 7, + }; + const result = mergeCompletionResults(shorter, longer, Infinity)!; + expect(result.matchedPrefixLength).toBe(7); + expect(result.groups).toHaveLength(2); + expect( + result.groups.find((g) => g.name === "long-kw")!.separatorMode, + ).toBe("spacePunctuation"); + expect( + result.groups.find((g) => g.name === "long-ent")!.separatorMode, + ).toBe("autoSpacePunctuation"); + // shorter group is discarded + expect( + result.groups.find((g) => g.name === "short"), + ).toBeUndefined(); + }); + + it("handles all six SeparatorMode values in a single merge", () => { + const first: CompletionResult = { + groups: [ + { + name: "g-space", + completions: ["a"], + separatorMode: "space", + }, + { + name: "g-spacePunct", + completions: ["b"], + separatorMode: "spacePunctuation", + }, + { + name: "g-optSpace", + completions: ["c"], + separatorMode: "optionalSpace", + }, + ], + matchedPrefixLength: 5, + }; + const second: CompletionResult = { + groups: [ + { + name: "g-optSpacePunct", + completions: ["d"], + separatorMode: "optionalSpacePunctuation", + }, + { + name: "g-none", + completions: ["e"], + separatorMode: "none", + }, + { + name: "g-auto", + completions: ["f"], + separatorMode: "autoSpacePunctuation", + }, + ], + matchedPrefixLength: 5, + }; + const result = mergeCompletionResults(first, second, Infinity)!; + expect(result.groups).toHaveLength(6); + + const modes = result.groups.map((g) => ({ + name: g.name, + mode: g.separatorMode, + })); + expect(modes).toContainEqual({ + name: "g-space", + mode: "space", + }); + expect(modes).toContainEqual({ + name: "g-spacePunct", + mode: "spacePunctuation", + }); + expect(modes).toContainEqual({ + name: "g-optSpace", + mode: "optionalSpace", + }); + expect(modes).toContainEqual({ + name: "g-optSpacePunct", + mode: "optionalSpacePunctuation", + }); + expect(modes).toContainEqual({ + name: "g-none", + mode: "none", + }); + expect(modes).toContainEqual({ + name: "g-auto", + mode: "autoSpacePunctuation", + }); + }); + }); }); diff --git a/ts/packages/dispatcher/dispatcher/package.json b/ts/packages/dispatcher/dispatcher/package.json index 2005a70f8..41e50ac7b 100644 --- a/ts/packages/dispatcher/dispatcher/package.json +++ b/ts/packages/dispatcher/dispatcher/package.json @@ -19,6 +19,7 @@ "./helpers/config": "./dist/helpers/config.js", "./helpers/status": "./dist/helpers/status.js", "./helpers/command": "./dist/helpers/command.js", + "./helpers/completion": "./dist/helpers/completion.js", "./internal": "./dist/internal.js", "./explorer": "./dist/explorer.js" }, diff --git a/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts b/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts new file mode 100644 index 000000000..cecc97205 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts @@ -0,0 +1,9 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Re-export completion utilities from action-grammar for consumers +// (like the shell renderer) that import through the dispatcher. +// Uses the lightweight action-grammar/completion subpath to avoid +// pulling in Node.js-only modules from the full barrel export. + +export { needsSeparatorInAutoMode } from "action-grammar/completion"; diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index b13e4edc5..e0d8da07d 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -510,6 +510,177 @@ const throwAgent: AppAgent = { ...getCommandInterface(throwHandlers), }; +// --------------------------------------------------------------------------- +// automode agent — returns groups with autoSpacePunctuation / +// spacePunctuation / none and matchedPrefixLength=0. Exercises the +// separator override logic in getCommandParameterCompletion when the +// agent has NOT advanced the prefix. +// --------------------------------------------------------------------------- +const automodeHandlers = { + description: "Agent returning autoSpacePunctuation groups", + defaultSubCommand: "auto", + commands: { + auto: { + description: "Returns autoSpacePunctuation with mpl=0", + parameters: { + args: { + entity: { + description: "An entity", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("entity")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "AutoEntities", + completions: ["タワー", "駅"], + separatorMode: "autoSpacePunctuation", + }, + ], + matchedPrefixLength: 0, + }; + }, + }, + spacepunct: { + description: "Returns spacePunctuation with mpl=0", + parameters: { + args: { + entity: { + description: "An entity", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("entity")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "SpacePunctEntities", + completions: ["alpha", "beta"], + separatorMode: "spacePunctuation", + }, + ], + matchedPrefixLength: 0, + }; + }, + }, + optpunct: { + description: "Returns optionalSpacePunctuation with mpl=0", + parameters: { + args: { + entity: { + description: "An entity", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("entity")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "OptPunctEntities", + completions: ["gamma", "delta"], + separatorMode: "optionalSpacePunctuation", + }, + ], + matchedPrefixLength: 0, + }; + }, + }, + nonemode: { + description: "Returns none separatorMode with mpl=0", + parameters: { + args: { + entity: { + description: "An entity", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("entity")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "NoneEntities", + completions: ["epsilon", "zeta"], + separatorMode: "none", + }, + ], + matchedPrefixLength: 0, + }; + }, + }, + nompl: { + description: "Returns groups with no matchedPrefixLength", + parameters: { + args: { + entity: { + description: "An entity", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("entity")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "NoMplEntities", + completions: ["eta", "theta"], + separatorMode: "autoSpacePunctuation", + }, + ], + }; + }, + }, + }, +} as const; + +const automodeConfig: AppAgentManifest = { + emojiChar: "🔀", + description: "Automode completion test", +}; + +const automodeAgent: AppAgent = { + ...getCommandInterface(automodeHandlers), +}; + const testCompletionAgentProviderMulti: AppAgentProvider = { getAppAgentNames: () => [ "comptest", @@ -517,6 +688,7 @@ const testCompletionAgentProviderMulti: AppAgentProvider = { "nocmdtest", "numstrtest", "throwtest", + "automodetest", ], getAppAgentManifest: async (name: string) => { if (name === "comptest") return config; @@ -524,6 +696,7 @@ const testCompletionAgentProviderMulti: AppAgentProvider = { if (name === "nocmdtest") return noCommandsConfig; if (name === "numstrtest") return numstrConfig; if (name === "throwtest") return throwConfig; + if (name === "automodetest") return automodeConfig; throw new Error(`Unknown: ${name}`); }, loadAppAgent: async (name: string) => { @@ -532,6 +705,7 @@ const testCompletionAgentProviderMulti: AppAgentProvider = { if (name === "nocmdtest") return noCommandsAgent; if (name === "numstrtest") return numstrAgent; if (name === "throwtest") return throwAgent; + if (name === "automodetest") return automodeAgent; throw new Error(`Unknown: ${name}`); }, unloadAppAgent: async (name: string) => { @@ -542,6 +716,7 @@ const testCompletionAgentProviderMulti: AppAgentProvider = { "nocmdtest", "numstrtest", "throwtest", + "automodetest", ].includes(name) ) throw new Error(`Unknown: ${name}`); @@ -1877,4 +2052,188 @@ describe("Command Completion - startIndex", () => { expect(result.afterWildcard).toBe("none"); }); }); + + // ── Gap 1: separatorMode override logic when matchedPrefixLength=0 ── + + describe("separatorMode override when agent has not advanced prefix (mpl=0)", () => { + // When the agent returns matchedPrefixLength=0 (or undefined), + // the dispatcher overrides per-group separatorMode based on + // target.separatorMode (which reflects trailing whitespace). + // Groups with autoSpacePunctuation / spacePunctuation / + // optionalSpacePunctuation are resolved to either + // "optionalSpacePunctuation" (if target has "optionalSpace") + // or "spacePunctuation" (otherwise). Other modes get the + // target's separatorMode directly. + + it("autoSpacePunctuation with trailing space → optionalSpacePunctuation", async () => { + // "@automodetest auto " — trailing space makes target.separatorMode = "optionalSpace" + const result = await getCommandCompletion( + "@automodetest auto ", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "AutoEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("optionalSpacePunctuation"); + }); + + it("autoSpacePunctuation without trailing space → spacePunctuation", async () => { + // "@automodetest auto" — no trailing space, target.separatorMode = undefined + const result = await getCommandCompletion( + "@automodetest auto", + "forward", + context, + ); + // Agent returns mpl=0 and autoSpacePunctuation. + // target.separatorMode is undefined (no trailing whitespace + // after the implicit-default-subcommand boundary at pos 13). + // Override: auto/spacePunct/optPunct without optionalSpace → "spacePunctuation". + const group = result.completions.find( + (g) => g.name === "AutoEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("spacePunctuation"); + }); + + it("spacePunctuation with trailing space → optionalSpacePunctuation", async () => { + const result = await getCommandCompletion( + "@automodetest spacepunct ", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "SpacePunctEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("optionalSpacePunctuation"); + }); + + it("spacePunctuation without trailing space → spacePunctuation", async () => { + const result = await getCommandCompletion( + "@automodetest spacepunct", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "SpacePunctEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("spacePunctuation"); + }); + + it("optionalSpacePunctuation with trailing space → optionalSpacePunctuation", async () => { + const result = await getCommandCompletion( + "@automodetest optpunct ", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "OptPunctEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("optionalSpacePunctuation"); + }); + + it("optionalSpacePunctuation without trailing space → spacePunctuation", async () => { + const result = await getCommandCompletion( + "@automodetest optpunct", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "OptPunctEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("spacePunctuation"); + }); + + it("none mode with trailing space → overridden to optionalSpace", async () => { + const result = await getCommandCompletion( + "@automodetest nonemode ", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "NoneEntities", + ); + expect(group).toBeDefined(); + // "none" is not auto/spacePunct/optPunct so takes target.separatorMode directly. + expect(group!.separatorMode).toBe("optionalSpace"); + }); + + it("none mode without trailing space → overridden to target (undefined)", async () => { + const result = await getCommandCompletion( + "@automodetest nonemode", + "forward", + context, + ); + const group = result.completions.find( + (g) => g.name === "NoneEntities", + ); + expect(group).toBeDefined(); + // target.separatorMode is undefined (default "space") + expect(group!.separatorMode).toBeUndefined(); + }); + + it("undefined matchedPrefixLength triggers override path", async () => { + const result = await getCommandCompletion( + "@automodetest nompl ", + "forward", + context, + ); + // nompl handler returns no matchedPrefixLength at all. + // Override branch fires (groupPrefixLength undefined). + const group = result.completions.find( + (g) => g.name === "NoMplEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("optionalSpacePunctuation"); + }); + }); + + // ── Gap 2: inferSeparatorMode at position 0 ────────────────────────── + + describe("inferSeparatorMode at position 0 (empty parameter input)", () => { + it("returns optionalSpace for resolved agent with no parameter text", async () => { + // "@automodetest " — default subcommand "auto" resolved, + // no parameter text typed. inferSeparatorMode(input, 14) + // sees trailing space → optionalSpace. + const result = await getCommandCompletion( + "@automodetest ", + "forward", + context, + ); + // Agent returns autoSpacePunctuation, trailing space makes + // target.separatorMode = "optionalSpace", auto → optionalSpacePunctuation. + const group = result.completions.find( + (g) => g.name === "AutoEntities", + ); + expect(group).toBeDefined(); + expect(group!.separatorMode).toBe("optionalSpacePunctuation"); + }); + + it("empty input (@) returns optionalSpace for agent names", async () => { + // Position 0 in "@" → inferSeparatorMode returns "optionalSpace" + // at the command level. + const result = await getCommandCompletion("@", "forward", context); + const agentNamesGroup = result.completions.find( + (g) => g.name === "Agent Names", + ); + expect(agentNamesGroup).toBeDefined(); + expect(agentNamesGroup!.separatorMode).toBe("optionalSpace"); + }); + + it("completely empty input returns optionalSpace", async () => { + const result = await getCommandCompletion("", "forward", context); + // System completions at position 0. + expect(result).toBeDefined(); + for (const group of result.completions) { + // All groups at position 0 should have optionalSpace + // since inferSeparatorMode(text, 0) returns "optionalSpace". + expect(group.separatorMode).toBe("optionalSpace"); + } + }); + }); }); diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 89f7213ec..e4d9caab6 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { CommandCompletionResult } from "agent-dispatcher"; +import { needsSeparatorInAutoMode } from "agent-dispatcher/helpers/completion"; import { AfterWildcard, CompletionDirection, @@ -14,29 +15,6 @@ import { } from "../../preload/electronTypes.js"; import registerDebug from "debug"; -// Inline auto-separator resolution (copy of action-grammar's -// needsSeparatorInAutoMode). Inlined to avoid importing action-grammar -// into the renderer — that package has Node.js-only modules that the -// Vite browser bundle can't resolve. -// SYNC: keep in sync with grammarMatcher.ts needsSeparatorInAutoMode. -const wordBoundaryScriptRe = - /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}|\p{Script=Armenian}|\p{Script=Georgian}|\p{Script=Hangul}|\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Devanagari}|\p{Script=Bengali}|\p{Script=Tamil}|\p{Script=Telugu}|\p{Script=Kannada}|\p{Script=Malayalam}|\p{Script=Gujarati}|\p{Script=Gurmukhi}|\p{Script=Oriya}|\p{Script=Sinhala}|\p{Script=Ethiopic}|\p{Script=Mongolian}/u; -const digitRe = /[0-9]/; - -function needsSeparatorInAutoMode(a: string, b: string): boolean { - if (digitRe.test(a) && digitRe.test(b)) { - return true; - } - const isWordBoundary = (c: string): boolean => { - const code = c.charCodeAt(0); - if (code < 128) { - return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); - } - return wordBoundaryScriptRe.test(c); - }; - return isWordBoundary(a) && isWordBoundary(b); -} - const debug = registerDebug("typeagent:shell:partial"); const debugError = registerDebug("typeagent:shell:partial:error"); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts index 3985ab4fc..957d0edab 100644 --- a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts @@ -750,3 +750,352 @@ describe("PartialCompletionSession — SepLevel transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); }); + +// ── Gap 3: autoSpacePunctuation resolution in the shell ────────────────── + +describe("PartialCompletionSession — autoSpacePunctuation", () => { + test("Latin-Latin pair resolves to spacePunctuation (separator required)", async () => { + // Input ends with "d" (Latin), completions start with Latin chars. + // needsSeparatorInAutoMode('d', 'a') → true → spacePunctuation. + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["alpha", "beta"], + separatorMode: "autoSpacePunctuation", + }, + ], + 4, // startIndex after "word" + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "word" — last char is 'd', completions start with 'a'/'b' (Latin). + // Auto-resolved to spacePunctuation → needs separator. + session.update("word", getPos); + await Promise.resolve(); + + // Level 0: spacePunctuation not visible → menu hidden. + expect(menu.isActive()).toBe(false); + + // Typing space: level 1, spacePunctuation visible. + session.update("word ", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.updatePrefix).toHaveBeenCalledWith("", anyPosition); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("Latin-CJK pair resolves to optionalSpacePunctuation (no separator needed)", async () => { + // Input ends with "d" (Latin), completions start with CJK. + // needsSeparatorInAutoMode('d', '東') → false → optionalSpacePunctuation. + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["東京", "大阪"], + separatorMode: "autoSpacePunctuation", + }, + ], + 4, // startIndex after "word" + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "word" — last char is 'd', completions start with '東'/'大' (CJK). + // Auto-resolved to optionalSpacePunctuation → no separator needed. + session.update("word", getPos); + await Promise.resolve(); + + // Level 0: optionalSpacePunctuation IS visible → menu shown. + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).toHaveBeenCalled(); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("CJK-CJK pair resolves to optionalSpacePunctuation", async () => { + // Input ends with CJK, completions start with CJK. + // needsSeparatorInAutoMode('京', '東') → false (CJK is not word-boundary). + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["タワー", "駅"], + separatorMode: "autoSpacePunctuation", + }, + ], + 2, // startIndex after "東京" + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "東京" — CJK-CJK → no separator needed. + session.update("東京", getPos); + await Promise.resolve(); + + expect(menu.isActive()).toBe(true); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("digit-digit pair resolves to spacePunctuation (separator required)", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["100", "200"], + separatorMode: "autoSpacePunctuation", + }, + ], + 3, // startIndex after "123" + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "123" — digit-digit → separator required. + session.update("123", getPos); + await Promise.resolve(); + + // Digit-digit pair → spacePunctuation → not visible at level 0. + expect(menu.isActive()).toBe(false); + + // Typing space shows menu. + session.update("123 ", getPos); + expect(menu.isActive()).toBe(true); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("mixed auto group splits items across partitions", async () => { + // A single group with autoSpacePunctuation where some items + // need a separator (Latin-Latin) and some don't (Latin-CJK). + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["alpha", "東京"], + separatorMode: "autoSpacePunctuation", + }, + ], + 4, // startIndex after "word" + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "word" — last char 'd' (Latin). + // "alpha" → needsSep('d','a') = true → spacePunctuation + // "東京" → needsSep('d','東') = false → optionalSpacePunctuation + session.update("word", getPos); + await Promise.resolve(); + + // Level 0: only optionalSpacePunctuation items visible → "東京". + expect(menu.isActive()).toBe(true); + // The trie should have the CJK item at level 0. + const prefix0 = menu.updatePrefix.mock.calls[0]?.[0]; + expect(prefix0).toBe(""); + + // Typing space → level 1: both spacePunctuation and optional visible. + menu.setChoices.mockClear(); + session.update("word ", getPos); + expect(menu.isActive()).toBe(true); + // Widen loaded both items at level 1. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("startIndex=0 uses empty string for character pair (no preceding char)", async () => { + // When startIndex is 0 there is no preceding character. + // needsSeparatorInAutoMode guard: startIndex > 0 is false + // → all items resolve to optionalSpacePunctuation. + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { + completions: ["hello", "world"], + separatorMode: "autoSpacePunctuation", + }, + ], + 0, // start of input + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("", getPos); + await Promise.resolve(); + + // All items → optionalSpacePunctuation → visible at level 0. + expect(menu.isActive()).toBe(true); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); + +// ── Gap 4: toPartitions bucketing (multiple groups with different modes) ── + +describe("PartialCompletionSession — multi-group partitioning", () => { + test("two groups with different modes show correct items per level", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { completions: ["cmd1", "cmd2"], separatorMode: "space" }, + { + completions: ["entity1"], + separatorMode: "optionalSpacePunctuation", + }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // "play" — level 0: only optionalSpacePunctuation visible. + session.update("play", getPos); + await Promise.resolve(); + + expect(menu.isActive()).toBe(true); + // Level 0 should only have "entity1" (optionalSpacePunctuation). + // "cmd1"/"cmd2" (space) need level 1. + expect(menu.setChoices).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ selectedText: "entity1" }), + ]), + ); + const lastCall = + menu.setChoices.mock.calls[menu.setChoices.mock.calls.length - 1]; + const items = lastCall[0]; + expect(items.every((i: any) => i.selectedText !== "cmd1")).toBe(true); + + // "play " — level 1: both "space" and "optionalSpacePunctuation" visible. + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + // Widen → new trie with all level-1 items. + expect(menu.setChoices).toHaveBeenCalledTimes(1); + const level1Items = menu.setChoices.mock.calls[0][0].map( + (i: any) => i.selectedText, + ); + expect(level1Items).toContain("cmd1"); + expect(level1Items).toContain("cmd2"); + expect(level1Items).toContain("entity1"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("space + spacePunctuation groups: space only at level 1, spacePunctuation at level 1 and 2", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { completions: ["flag"], separatorMode: "space" }, + { completions: ["entity"], separatorMode: "spacePunctuation" }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // Level 0: neither visible. + session.update("play", getPos); + await Promise.resolve(); + expect(menu.isActive()).toBe(false); + + // Level 1 (space): both visible. + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + const level1Items = menu.setChoices.mock.calls[ + menu.setChoices.mock.calls.length - 1 + ][0].map((i: any) => i.selectedText); + expect(level1Items).toContain("flag"); + expect(level1Items).toContain("entity"); + + // Level 2 (punctuation): only spacePunctuation. + menu.setChoices.mockClear(); + session.update("play.", getPos); + expect(menu.isActive()).toBe(true); + expect(menu.setChoices).toHaveBeenCalledTimes(1); + const level2Items = menu.setChoices.mock.calls[0][0].map( + (i: any) => i.selectedText, + ); + expect(level2Items).toContain("entity"); + expect(level2Items.includes("flag")).toBe(false); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("empty group produces no items", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { completions: [], separatorMode: "space" }, + { completions: ["only"], separatorMode: "optionalSpace" }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Only "only" (optionalSpace) at level 0. + expect(menu.isActive()).toBe(true); + const items = + menu.setChoices.mock.calls[ + menu.setChoices.mock.calls.length - 1 + ][0]; + expect(items).toHaveLength(1); + expect(items[0].selectedText).toBe("only"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("three groups at all levels provide correct item sets", async () => { + const menu = makeMenu(); + const result = makeMultiGroupResult( + [ + { completions: ["instant"], separatorMode: "none" }, + { completions: ["word"], separatorMode: "space" }, + { completions: ["punct"], separatorMode: "spacePunctuation" }, + ], + 4, + ); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + // Level 0: only "none" visible. + session.update("play", getPos); + await Promise.resolve(); + expect(menu.isActive()).toBe(true); + const level0Items = menu.setChoices.mock.calls[ + menu.setChoices.mock.calls.length - 1 + ][0].map((i: any) => i.selectedText); + expect(level0Items).toContain("instant"); + expect(level0Items).not.toContain("word"); + expect(level0Items).not.toContain("punct"); + + // Level 1: "space" + "spacePunctuation" visible, NOT "none". + menu.setChoices.mockClear(); + session.update("play ", getPos); + expect(menu.isActive()).toBe(true); + const level1Items = menu.setChoices.mock.calls[0][0].map( + (i: any) => i.selectedText, + ); + expect(level1Items).toContain("word"); + expect(level1Items).toContain("punct"); + expect(level1Items).not.toContain("instant"); + + // Level 2: only "spacePunctuation" visible. + menu.setChoices.mockClear(); + session.update("play.", getPos); + expect(menu.isActive()).toBe(true); + const level2Items = menu.setChoices.mock.calls[0][0].map( + (i: any) => i.selectedText, + ); + expect(level2Items).toContain("punct"); + expect(level2Items).not.toContain("word"); + expect(level2Items).not.toContain("instant"); + + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +}); From e56082432ee2d6eb8fd755b4562d163f51f74f51 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 16:50:56 -0700 Subject: [PATCH 09/10] Review fixes: remove dead needsSeparatorInAutoMode duplicate, clean up casts, fix docs, narrow inferSeparatorMode type - Remove dead needsSeparatorInAutoMode + helpers from cache/utils/language.ts (no longer imported after constructionCache switched to action-grammar) - Remove unnecessary 'as unknown as CompiledSpacingMode' casts in spacingModeToSeparatorMode.spec.ts (undefined is a valid union member) - Fix actionGrammar.md auto-spacing doc table: auto mode produces 'autoSpacePunctuation', resolved per-item to spacePunctuation or optionalSpacePunctuation (was incorrectly showing optionalSpace) - Narrow inferSeparatorMode return type to 'optionalSpace' | undefined with clarified comment explaining undefined semantics --- ts/docs/architecture/actionGrammar.md | 28 ++++++++++--------- .../test/spacingModeToSeparatorMode.spec.ts | 10 +++---- ts/packages/cache/src/utils/language.ts | 26 ----------------- .../dispatcher/src/command/completion.ts | 6 ++-- 4 files changed, 23 insertions(+), 47 deletions(-) diff --git a/ts/docs/architecture/actionGrammar.md b/ts/docs/architecture/actionGrammar.md index 1c266f0c3..504d10f28 100644 --- a/ts/docs/architecture/actionGrammar.md +++ b/ts/docs/architecture/actionGrammar.md @@ -133,21 +133,23 @@ evaluated against the adjacent characters to produce a `separatorMode` [Completion matching](#completion-matching-matchgrammarcompletion) and `completion.md`): -| Annotation | `CompiledSpacingMode` | Resulting `separatorMode` | -| -------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| _(none / default)_ | `auto` | `"spacePunctuation"` if both adjacent characters are word-boundary scripts (Latin, Cyrillic, etc.); `"optionalSpace"` if either is CJK or another non-word-boundary script | -| `[spacing=required]` | `"required"` | Always `"spacePunctuation"` | -| `[spacing=optional]` | `"optional"` | Always `"optionalSpacePunctuation"` | -| `[spacing=none]` | `"none"` | Always `"none"` — no separator consumed or required | +| Annotation | `CompiledSpacingMode` | Resulting `separatorMode` | +| -------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| _(none / default)_ | `auto` | `"autoSpacePunctuation"` — resolved per-item by the consumer: `"spacePunctuation"` if both adjacent characters are word-boundary scripts (Latin, Cyrillic, etc.); `"optionalSpacePunctuation"` if either is CJK or another non-word-boundary script | +| `[spacing=required]` | `"required"` | Always `"spacePunctuation"` | +| `[spacing=optional]` | `"optional"` | Always `"optionalSpacePunctuation"` | +| `[spacing=none]` | `"none"` | Always `"none"` — no separator consumed or required | **Note:** The table above describes the _baseline_ `separatorMode` -from the spacing annotation. When the consumed prefix already ends with -whitespace (i.e., the separator is already present in `matchedPrefixLength`), -the grammar matcher overrides to `"optionalSpace"` because no additional -separator is needed. Digits are Unicode script "Common" (not a -word-boundary script), so `auto` spacing at a digit–Latin boundary -(e.g., `$(n:number)` followed by a Latin keyword) also produces -`"optionalSpace"`. +from the spacing annotation. For `auto` mode, the grammar emits +`"autoSpacePunctuation"` and the shell resolves each item to +`"spacePunctuation"` or `"optionalSpacePunctuation"` based on the +character pair (see `toPartitions()` in `partialCompletionSession.ts`). +At the command/flag level, the dispatcher may override to +`"optionalSpace"` when trailing whitespace was already consumed. +Digits are Unicode script "Common" (not a word-boundary script), +so `auto` spacing at a digit–Latin boundary (e.g., `$(n:number)` +followed by a Latin keyword) resolves to `"optionalSpacePunctuation"`. ### Entities diff --git a/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts b/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts index 6e60e6fb8..632922a16 100644 --- a/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts +++ b/ts/packages/actionGrammar/test/spacingModeToSeparatorMode.spec.ts @@ -23,11 +23,9 @@ describe("spacingModeToSeparatorMode", () => { }); it('maps undefined (auto) → "autoSpacePunctuation"', () => { - expect( - spacingModeToSeparatorMode( - undefined as unknown as CompiledSpacingMode, - ), - ).toBe("autoSpacePunctuation"); + expect(spacingModeToSeparatorMode(undefined)).toBe( + "autoSpacePunctuation", + ); }); it("covers all CompiledSpacingMode values exhaustively", () => { @@ -36,7 +34,7 @@ describe("spacingModeToSeparatorMode", () => { "required", "optional", "none", - undefined as unknown as CompiledSpacingMode, + undefined, ]; for (const mode of modes) { const result = spacingModeToSeparatorMode(mode); diff --git a/ts/packages/cache/src/utils/language.ts b/ts/packages/cache/src/utils/language.ts index 3871c1431..acd0dd25f 100644 --- a/ts/packages/cache/src/utils/language.ts +++ b/ts/packages/cache/src/utils/language.ts @@ -414,29 +414,3 @@ export function getLanguageTools(language: string): LanguageTools | undefined { return languageToolsEn; } - -// --------------------------------------------------------------------------- -// Separator heuristic — inlined from action-grammar's grammarMatcher to avoid -// pulling the full barrel (which includes Node-only modules) into browser -// bundles via the cache's indexBrowser entry point. -// --------------------------------------------------------------------------- - -const wordBoundaryScriptRe = - /\p{Script=Latin}|\p{Script=Cyrillic}|\p{Script=Greek}|\p{Script=Armenian}|\p{Script=Georgian}|\p{Script=Hangul}|\p{Script=Arabic}|\p{Script=Hebrew}|\p{Script=Devanagari}|\p{Script=Bengali}|\p{Script=Tamil}|\p{Script=Telugu}|\p{Script=Kannada}|\p{Script=Malayalam}|\p{Script=Gujarati}|\p{Script=Gurmukhi}|\p{Script=Oriya}|\p{Script=Sinhala}|\p{Script=Ethiopic}|\p{Script=Mongolian}/u; - -const digitRe = /[0-9]/; - -function isWordBoundaryScript(c: string): boolean { - const code = c.charCodeAt(0); - if (code < 128) { - return (code >= 65 && code <= 90) || (code >= 97 && code <= 122); - } - return wordBoundaryScriptRe.test(c); -} - -export function needsSeparatorInAutoMode(a: string, b: string): boolean { - if (digitRe.test(a) && digitRe.test(b)) { - return true; - } - return isWordBoundaryScript(a) && isWordBoundaryScript(b); -} diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index 502ea4746..d09bd0843 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -79,11 +79,13 @@ function detectPendingFlag( // Returns the separatorMode implied by the position in the input. // When the position is 0 (beginning of input) or preceded by // whitespace, the separator is already satisfied → "optionalSpace". -// Otherwise returns undefined (defaults to "space" per CompletionGroup contract). +// Otherwise returns undefined — callers treat undefined as "separator +// not yet present" and leave the group's own separatorMode intact +// (which defaults to "space" per the CompletionGroup contract). function inferSeparatorMode( text: string, index: number, -): SeparatorMode | undefined { +): "optionalSpace" | undefined { return index === 0 || /\s/.test(text[index - 1]) ? "optionalSpace" : undefined; From ff25b57b610bf28199b1b81e455cf9c23bec6bc0 Mon Sep 17 00:00:00 2001 From: Curtis Man Date: Wed, 8 Apr 2026 19:19:46 -0700 Subject: [PATCH 10/10] lint:fix --- ts/docs/architecture/agentServerSessions.md | 60 ++++++++++--------- .../cache/test/grammarIntegration.spec.ts | 22 ++++--- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/ts/docs/architecture/agentServerSessions.md b/ts/docs/architecture/agentServerSessions.md index 34b6dff02..439674991 100644 --- a/ts/docs/architecture/agentServerSessions.md +++ b/ts/docs/architecture/agentServerSessions.md @@ -1,7 +1,7 @@ -# AgentServer Sessions Architecture +# AgentServer Sessions Architecture **Author:** George Ng -**Status:** Review +**Status:** Review **Last Updated:** 2026-04-03 --- @@ -57,8 +57,8 @@ The `join()` call today accepts only: ```typescript type DispatcherConnectOptions = { - filter?: boolean; - clientType?: "shell" | "extension"; + filter?: boolean; + clientType?: "shell" | "extension"; }; ``` @@ -72,12 +72,12 @@ There is no way for a client to specify which session to use, or to perform sess Each session is identified by: -| Field | Type | Description | -|---|---|---| -| `sessionId` | `string` (UUIDv4) | Stable, globally unique identifier | -| `name` | `string` | Human-readable label (1–256 chars), set by the caller at `createSession()` time. Not enforced unique. | -| `createdAt` | `string` (ISO 8601) | When the session was first created | -| `clientCount` | `number` | Number of clients currently connected to this session (runtime-derived; `0` if the session is not loaded in memory). **Never persisted** — see Section 2. | +| Field | Type | Description | +| ------------- | ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `sessionId` | `string` (UUIDv4) | Stable, globally unique identifier | +| `name` | `string` | Human-readable label (1–256 chars), set by the caller at `createSession()` time. Not enforced unique. | +| `createdAt` | `string` (ISO 8601) | When the session was first created | +| `clientCount` | `number` | Number of clients currently connected to this session (runtime-derived; `0` if the session is not loaded in memory). **Never persisted** — see Section 2. | ### 2. Session Metadata @@ -108,11 +108,11 @@ Each session's full data (chat history, conversation memory, display log) is sto ```typescript type DispatcherConnectOptions = { - filter?: boolean; - clientType?: "shell" | "extension"; + filter?: boolean; + clientType?: "shell" | "extension"; - // Session management (new) - sessionId?: string; // Join a specific session by UUID. If omitted → resumes most recently active session. + // Session management (new) + sessionId?: string; // Join a specific session by UUID. If omitted → resumes most recently active session. }; ``` @@ -122,15 +122,17 @@ The existing `join` RPC is replaced by `joinSession`. A `leaveSession` call is a ```typescript type AgentServerInvokeFunctions = { - // Replaces the old `join` - joinSession: (options?: DispatcherConnectOptions) => Promise; - leaveSession: (sessionId: string) => Promise; - - // Session CRUD - createSession: (name: string) => Promise; - listSessions: (name?: string) => Promise; - renameSession: (sessionId: string, newName: string) => Promise; - deleteSession: (sessionId: string) => Promise; + // Replaces the old `join` + joinSession: ( + options?: DispatcherConnectOptions, + ) => Promise; + leaveSession: (sessionId: string) => Promise; + + // Session CRUD + createSession: (name: string) => Promise; + listSessions: (name?: string) => Promise; + renameSession: (sessionId: string, newName: string) => Promise; + deleteSession: (sessionId: string) => Promise; }; ``` @@ -138,8 +140,8 @@ type AgentServerInvokeFunctions = { ```typescript type JoinSessionResult = { - connectionId: string; - sessionId: string; // The session that was joined or auto-created + connectionId: string; + sessionId: string; // The session that was joined or auto-created }; ``` @@ -149,10 +151,10 @@ type JoinSessionResult = { ```typescript type SessionInfo = { - sessionId: string; - name: string; - clientCount: number; - createdAt: string; + sessionId: string; + name: string; + clientCount: number; + createdAt: string; }; ``` diff --git a/ts/packages/cache/test/grammarIntegration.spec.ts b/ts/packages/cache/test/grammarIntegration.spec.ts index 50f65d78c..dfd57516b 100644 --- a/ts/packages/cache/test/grammarIntegration.spec.ts +++ b/ts/packages/cache/test/grammarIntegration.spec.ts @@ -720,10 +720,13 @@ describe("Grammar Integration", () => { // May or may not have completions depending on grammar structure // Main assertion is that completion() works without error in NFA mode - if (completions && completions.groups.flatMap(g => g.completions).length > 0) { - const completionStrings = completions.groups.flatMap(g => g.completions).map((c) => - c.toLowerCase(), - ); + if ( + completions && + completions.groups.flatMap((g) => g.completions).length > 0 + ) { + const completionStrings = completions.groups + .flatMap((g) => g.completions) + .map((c) => c.toLowerCase()); console.log("NFA completions:", completionStrings); } }); @@ -759,10 +762,13 @@ describe("Grammar Integration", () => { expect(completions).toBeDefined(); // Completion system exists and works in completion-based mode - if (completions && completions.groups.flatMap(g => g.completions).length > 0) { + if ( + completions && + completions.groups.flatMap((g) => g.completions).length > 0 + ) { console.log( "Completion-based completions:", - completions.groups.flatMap(g => g.completions), + completions.groups.flatMap((g) => g.completions), ); } }); @@ -881,7 +887,9 @@ describe("Grammar Integration", () => { // separator before "b" is stripped), NOT at 17 (EOI where // localPlayer's wildcard absorbs everything) expect(completions!.matchedPrefixLength).toBe(15); - expect(completions!.groups.flatMap(g => g.completions)).toContain("by"); + expect(completions!.groups.flatMap((g) => g.completions)).toContain( + "by", + ); }); });