diff --git a/ts/docs/architecture/completion.md b/ts/docs/architecture/completion.md index 6156e12d0..b9f0570e3 100644 --- a/ts/docs/architecture/completion.md +++ b/ts/docs/architecture/completion.md @@ -77,6 +77,7 @@ The return path carries `CommandCompletionResult`: separatorMode?: SeparatorMode; // "space" | "spacePunctuation" | "optional" | "none" closedSet: boolean; // true → list is exhaustive directionSensitive: boolean; // true → opposite direction would produce different results + openWildcard: boolean; // true → wildcard boundary is ambiguous; shell should slide anchor } ``` @@ -117,6 +118,10 @@ grammar rules. needed at the boundary (Latin vs CJK, `[spacing=none]` rules). - `closedSet` — `true` for pure keyword alternatives; `false` when property/wildcard completions are emitted (entity values are external). +- `openWildcard` — `true` when a keyword completion is offered after a + wildcard that was finalized at end-of-input (Category 2 where the + preceding wildcard consumed the entire remaining input). Signals that + the wildcard boundary is ambiguous. --- @@ -264,11 +269,11 @@ lifecycle of a completion interaction. | ---- | ------------------------------------------------ | ------------ | ------------------- | | A1 | No active session | Invalidation | Re-fetch | | A2 | Input no longer extends anchor | Invalidation | Re-fetch | -| A3 | Non-separator char typed when separator required | Invalidation | Re-fetch | +| A3 | Non-separator char typed when separator required | Invalidation | Re-fetch (or slide) | | A7 | Direction changed on direction-sensitive result | Invalidation | Re-fetch | | B4 | Unique match (always fires) | Navigation | Re-fetch next level | | B5 | Separator typed after exact match | Navigation | Re-fetch next level | -| C6 | No trie matches + open set | Discovery | Re-fetch | +| C6 | No trie matches + open set | Discovery | Re-fetch (or slide) | | — | Trie has matches | — | Reuse locally | | — | No matches + closed set | — | Reuse (menu hidden) | @@ -360,6 +365,38 @@ A boolean flowing through the entire pipeline: Merge rule: AND across sources (closed only if _all_ sources are closed). +### `openWildcard` + +A boolean flowing through the entire pipeline, signaling that the completions +are offered at a position where a wildcard was finalized at end-of-input. + +- **`true`** — the wildcard's extent is ambiguous (the user may still be + typing within it). The keyword following the wildcard (e.g. "by") is + offered as a completion, and `closedSet` correctly describes that keyword + set as exhaustive. However, the _position_ of that set is uncertain. + + The shell handles this with **anchor sliding**: instead of re-fetching + (which would return the same keyword at a shifted position) or giving up + (stuck when `closedSet=true`), the shell slides the anchor forward to the + current input. The trie and metadata stay intact, so the menu re-appears + at the next word boundary when the user types a separator. + + Recovery is automatic: when the user eventually types the keyword and it + uniquely matches in the trie (trigger B4), the session re-fetches for the + next grammar part. + +- **`false`** — no sliding wildcard boundary; normal `closedSet` semantics + apply. + +Merge rule: OR across sources (open wildcard if _any_ source has one). + +Affects triggers A3 and C6 in the re-fetch decision tree: + +- **A3** (non-separator after anchor): when `openWildcard=true`, the anchor + slides forward instead of triggering a re-fetch. +- **C6** (trie empty, closed set): when `openWildcard=true`, the anchor + slides forward instead of staying permanently hidden. + --- ## CLI integration diff --git a/ts/packages/actionGrammar/src/grammarMatcher.ts b/ts/packages/actionGrammar/src/grammarMatcher.ts index 312148597..24af14dc1 100644 --- a/ts/packages/actionGrammar/src/grammarMatcher.ts +++ b/ts/packages/actionGrammar/src/grammarMatcher.ts @@ -1176,6 +1176,12 @@ export type GrammarCompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive: boolean; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard: boolean; }; function getGrammarCompletionProperty( @@ -1412,6 +1418,13 @@ export function matchGrammarCompletion( // whenever maxPrefixLength advances (old candidates discarded). let directionSensitive = false; + // Whether a wildcard was finalized at end-of-input and the + // following keyword was offered as a completion. When true the + // wildcard boundary is ambiguous — the user may still be typing + // within the wildcard — so the caller should slide its anchor + // forward instead of re-fetching or giving up. + let openWildcard = false; + // Helper: update maxPrefixLength. When it increases, all previously // accumulated completions from shorter matches are irrelevant // — clear them. @@ -1423,6 +1436,7 @@ export function matchGrammarCompletion( separatorMode = undefined; closedSet = true; directionSensitive = false; + openWildcard = false; } } @@ -1626,6 +1640,15 @@ export function matchGrammarCompletion( if (hasPartToReconsider) { directionSensitive = true; } + // When a wildcard was finalized at end-of-input and we + // offered the following keyword as a completion, the + // wildcard boundary is ambiguous — the user may still be + // typing within the wildcard. Signal this so the shell + // can slide its anchor forward instead of re-fetching or + // giving up when the user keeps typing. + if (savedPendingWildcard?.valueId !== undefined) { + openWildcard = true; + } // Note: non-string next parts (wildcard, number, rules) in // Category 2 don't produce completions here — wildcards are // handled by Category 3a (pending wildcard) and nested rules @@ -1714,6 +1737,7 @@ export function matchGrammarCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; debugCompletion(`Completed. ${JSON.stringify(result)}`); return result; diff --git a/ts/packages/actionGrammar/src/nfaCompletion.ts b/ts/packages/actionGrammar/src/nfaCompletion.ts index aaba8fc9a..dcb16b130 100644 --- a/ts/packages/actionGrammar/src/nfaCompletion.ts +++ b/ts/packages/actionGrammar/src/nfaCompletion.ts @@ -261,7 +261,11 @@ export function computeNFACompletions( if (reachableStates.length === 0) { debugCompletion(` → no reachable states, returning empty`); - return { completions: [], directionSensitive: false }; + return { + completions: [], + directionSensitive: false, + openWildcard: false, + }; } // Explore completions from reachable states @@ -286,6 +290,7 @@ export function computeNFACompletions( const result: GrammarCompletionResult = { completions: uniqueCompletions, directionSensitive: false, + openWildcard: false, }; const grammarProperties = buildGrammarProperties(nfa, properties); if (grammarProperties.length > 0) { diff --git a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts index 893a2bc9c..0192bbe4d 100644 --- a/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts +++ b/ts/packages/actionGrammar/test/grammarCompletionLongestMatch.spec.ts @@ -493,6 +493,7 @@ describe("Grammar Completion - longest match property", () => { const result = matchGrammarCompletion(grammar, ""); expect(result.completions).toContain("play"); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); it("closedSet=true for alternatives after prefix", () => { @@ -501,12 +502,14 @@ describe("Grammar Completion - longest match property", () => { expect(result.completions).toContain("pop"); expect(result.completions).toContain("jazz"); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); it("closedSet=true for exact match (no completions)", () => { const result = matchGrammarCompletion(grammar, "play rock"); expect(result.completions).toHaveLength(0); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(false); }); }); @@ -545,12 +548,46 @@ describe("Grammar Completion - longest match property", () => { expect(result.properties).toBeDefined(); expect(result.properties!.length).toBeGreaterThan(0); expect(result.closedSet).toBe(false); + expect(result.openWildcard).toBe(false); }); it("closedSet=true for 'by' keyword after wildcard captured", () => { const result = matchGrammarCompletion(grammar, "play hello"); expect(result.completions).toContain("by"); expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true for multi-word wildcard before keyword", () => { + const result = matchGrammarCompletion( + grammar, + "play my favorite song", + ); + expect(result.completions).toContain("by"); + expect(result.closedSet).toBe(true); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true for ambiguous keyword boundary", () => { + // "play hello by" is ambiguous: "by" could be part of + // the track name or the keyword delimiter. The grammar + // produces both interpretations at prefix length 13, + // so openWildcard stays true. + const result = matchGrammarCompletion(grammar, "play hello by"); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard=true persists with trailing separator", () => { + // "play hello by " — still ambiguous: "by " could be + // part of the track name in one interpretation. + // openWildcard stays true because the grammar considers + // both parse paths. The shell resolves this via B4 + // (unique match) when the user types "by" in the trie. + const result = matchGrammarCompletion( + grammar, + "play hello by ", + ); + expect(result.openWildcard).toBe(true); }); }); diff --git a/ts/packages/agentSdk/src/command.ts b/ts/packages/agentSdk/src/command.ts index 0ffbb219b..cbacd1d02 100644 --- a/ts/packages/agentSdk/src/command.ts +++ b/ts/packages/agentSdk/src/command.ts @@ -112,6 +112,12 @@ export type CompletionGroups = { // direction change. When omitted, the dispatcher will conservatively // assume true if matchedPrefixLength > 0 and false otherwise. directionSensitive?: boolean | undefined; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard?: boolean | undefined; }; export interface AppAgentCommandInterface { diff --git a/ts/packages/cache/src/cache/grammarStore.ts b/ts/packages/cache/src/cache/grammarStore.ts index de2b04f19..d70eba082 100644 --- a/ts/packages/cache/src/cache/grammarStore.ts +++ b/ts/packages/cache/src/cache/grammarStore.ts @@ -280,6 +280,7 @@ export class GrammarStoreImpl implements GrammarStore { let separatorMode: SeparatorMode | undefined; let closedSet: boolean | undefined; let directionSensitive: boolean = false; + let openWildcard: boolean = false; const filter = new Set(namespaceKeys); for (const [name, entry] of this.grammars) { if (filter && !filter.has(name)) { @@ -367,6 +368,7 @@ export class GrammarStoreImpl implements GrammarStore { separatorMode = undefined; closedSet = undefined; directionSensitive = false; + openWildcard = false; } if (partialPrefixLength === matchedPrefixLength) { completions.push(...partial.completions); @@ -388,6 +390,9 @@ export class GrammarStoreImpl implements GrammarStore { // length is direction-sensitive. directionSensitive = directionSensitive || partial.directionSensitive; + // True if any grammar result at this prefix + // length has an open wildcard. + openWildcard = openWildcard || partial.openWildcard; if ( partial.properties !== undefined && partial.properties.length > 0 @@ -418,6 +423,7 @@ export class GrammarStoreImpl implements GrammarStore { separatorMode, closedSet, directionSensitive, + openWildcard, }; } } diff --git a/ts/packages/cache/src/constructions/constructionCache.ts b/ts/packages/cache/src/constructions/constructionCache.ts index 2a9f8a793..63031c894 100644 --- a/ts/packages/cache/src/constructions/constructionCache.ts +++ b/ts/packages/cache/src/constructions/constructionCache.ts @@ -95,6 +95,12 @@ export type CompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive?: boolean | undefined; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard?: boolean | undefined; }; // Architecture: docs/architecture/completion.md — §2 Cache Layer @@ -149,6 +155,13 @@ export function mergeCompletionResults( ? (first.directionSensitive ?? false) || (second.directionSensitive ?? false) : undefined, + // Open wildcard if either source has one. + openWildcard: + first.openWildcard !== undefined || + second.openWildcard !== undefined + ? (first.openWildcard ?? false) || + (second.openWildcard ?? false) + : undefined, }; } diff --git a/ts/packages/dispatcher/dispatcher/src/command/completion.ts b/ts/packages/dispatcher/dispatcher/src/command/completion.ts index b355e6021..a23f28c95 100644 --- a/ts/packages/dispatcher/dispatcher/src/command/completion.ts +++ b/ts/packages/dispatcher/dispatcher/src/command/completion.ts @@ -441,6 +441,7 @@ async function getCommandParameterCompletion( let agentClosedSet: boolean | undefined; let separatorMode: SeparatorMode | undefined = target.separatorMode; let directionSensitive = false; + let openWildcard = false; const agent = context.agents.getAppAgent(result.actualAppAgentName); if (agent.getCommandCompletion && target.completionNames.length > 0) { @@ -480,6 +481,7 @@ async function getCommandParameterCompletion( directionSensitive = agentResult.directionSensitive ?? (groupPrefixLength !== undefined && groupPrefixLength > 0); + openWildcard = agentResult.openWildcard ?? false; debug( `Command completion parameter with agent: groupPrefixLength=${groupPrefixLength}, startIndex=${startIndex}`, ); @@ -496,6 +498,7 @@ async function getCommandParameterCompletion( params.nextArgs.length > 0, ), directionSensitive: target.directionSensitive || directionSensitive, + openWildcard, }; } @@ -514,6 +517,7 @@ async function completeDescriptor( separatorMode: SeparatorMode | undefined; closedSet: boolean; directionSensitive: boolean; + openWildcard: boolean; }> { const completions: CompletionGroup[] = []; let separatorMode: SeparatorMode | undefined; @@ -555,6 +559,7 @@ async function completeDescriptor( separatorMode, closedSet: true, directionSensitive: false, + openWildcard: false, }; } @@ -568,6 +573,7 @@ async function completeDescriptor( ), closedSet: parameterCompletions.closedSet, directionSensitive: parameterCompletions.directionSensitive, + openWildcard: parameterCompletions.openWildcard, }; } @@ -677,6 +683,7 @@ export async function getCommandCompletion( // Track whether direction influenced the result. When false, // the caller can skip re-fetching on direction change. let directionSensitive = false; + let openWildcard = false; const descriptor = result.descriptor; @@ -738,6 +745,7 @@ export async function getCommandCompletion( // direction) or if the agent/parameter level is. directionSensitive = directionSensitiveCommand || desc.directionSensitive; + openWildcard = desc.openWildcard; } else if (table !== undefined) { // descriptor is undefined: the suffix didn't resolve to any // known command or subcommand. startIndex already points to @@ -803,6 +811,7 @@ export async function getCommandCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; debug(`Command completion result:`, completionResult); @@ -817,6 +826,7 @@ export async function getCommandCompletion( separatorMode: undefined, closedSet: false, directionSensitive: false, + openWildcard: false, }; } } 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 a31cce7ca..62d637e33 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/matchCommandHandler.ts @@ -67,6 +67,8 @@ export class MatchCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } return result; 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 083278dba..109e4a345 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/requestCommandHandler.ts @@ -490,6 +490,8 @@ export class RequestCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } return result; 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 85006f33e..fec9d23c0 100644 --- a/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts +++ b/ts/packages/dispatcher/dispatcher/src/context/dispatcher/handlers/translateCommandHandler.ts @@ -92,6 +92,8 @@ export class TranslateCommandHandler implements CommandHandler { result.matchedPrefixLength = requestResult.matchedPrefixLength; result.separatorMode = requestResult.separatorMode; result.closedSet = requestResult.closedSet; + result.directionSensitive = requestResult.directionSensitive; + result.openWildcard = requestResult.openWildcard; } } return result; diff --git a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts index 4c53308be..b5b6fd864 100644 --- a/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts +++ b/ts/packages/dispatcher/dispatcher/src/translation/requestCompletion.ts @@ -105,6 +105,7 @@ export async function requestCompletion( const separatorMode = results.separatorMode; const closedSet = results.closedSet; const directionSensitive = results.directionSensitive; + const openWildcard = results.openWildcard; const completions: CompletionGroup[] = []; if (results.completions.length > 0) { completions.push({ @@ -122,6 +123,7 @@ export async function requestCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; } @@ -148,6 +150,7 @@ export async function requestCompletion( separatorMode, closedSet, directionSensitive, + openWildcard, }; } diff --git a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts index b12af3cf5..21587b122 100644 --- a/ts/packages/dispatcher/dispatcher/test/completion.spec.ts +++ b/ts/packages/dispatcher/dispatcher/test/completion.spec.ts @@ -341,6 +341,39 @@ const handlers = { }; }, }, + wildcard: { + description: "Simulates grammar open-wildcard result", + parameters: { + args: { + request: { + description: "A request with wildcard", + }, + }, + }, + run: async () => {}, + getCompletion: async ( + _context: unknown, + _params: unknown, + names: string[], + ): Promise => { + if (!names.includes("request")) { + return { groups: [] }; + } + return { + groups: [ + { + name: "Keywords", + completions: ["by", "from"], + }, + ], + matchedPrefixLength: 5, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: true, + openWildcard: true, + }; + }, + }, }, } as const; @@ -1816,4 +1849,36 @@ describe("Command Completion - startIndex", () => { expect(result.directionSensitive).toBeFalsy(); }); }); + + describe("openWildcard propagation", () => { + it("propagates openWildcard=true from agent result", async () => { + const result = await getCommandCompletion( + "@comptest wildcard hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(true); + }); + + it("openWildcard defaults to false when agent does not set it", async () => { + const result = await getCommandCompletion( + "@comptest run ", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("openWildcard is false for commands with no agent completion", async () => { + const result = await getCommandCompletion( + "@comptest noop", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + }); }); diff --git a/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts new file mode 100644 index 000000000..0d96f9f34 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/requestCompletionPropagation.spec.ts @@ -0,0 +1,130 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + type CommandHandlerContext, + closeCommandHandlerContext, + initializeCommandHandlerContext, +} from "../src/context/commandHandlerContext.js"; +import { getCommandCompletion } from "../src/command/completion.js"; + +// --------------------------------------------------------------------------- +// Test: openWildcard and directionSensitive propagation through the +// request handler path (bare text → requestCommandHandler.getCompletion +// → requestCompletion → agentCache.completion) +// +// Strategy: initialize a context, then monkey-patch agentCache.completion +// to return a controlled CompletionResult with openWildcard and +// directionSensitive. Send bare text (no @-prefix) so the dispatcher +// routes through the request handler. +// --------------------------------------------------------------------------- + +describe("Request handler completion propagation", () => { + let context: CommandHandlerContext; + + beforeAll(async () => { + context = await initializeCommandHandlerContext("test", { + agents: { + actions: false, + schemas: false, + }, + translation: { enabled: false }, + explainer: { enabled: false }, + cache: { enabled: false }, + }); + }); + afterAll(async () => { + if (context) { + await closeCommandHandlerContext(context); + } + }); + + function patchCacheCompletion(result: unknown): void { + (context.agentCache as any).completion = () => result; + } + + afterEach(() => { + // Restore original (disabled cache returns undefined) + patchCacheCompletion(undefined); + }); + + it("propagates openWildcard=true from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["by", "from"], + matchedPrefixLength: 10, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: true, + openWildcard: true, + }); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(true); + expect(result.directionSensitive).toBe(true); + expect(result.closedSet).toBe(true); + expect(result.separatorMode).toBe("spacePunctuation"); + }); + + it("propagates openWildcard=false from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["music"], + matchedPrefixLength: 5, + separatorMode: "spacePunctuation", + closedSet: true, + directionSensitive: false, + openWildcard: false, + }); + + const result = await getCommandCompletion("play ", "forward", context); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("returns openWildcard=false when cache returns undefined", async () => { + patchCacheCompletion(undefined); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.openWildcard).toBe(false); + }); + + it("propagates directionSensitive through request handler", async () => { + patchCacheCompletion({ + completions: ["by"], + matchedPrefixLength: 5, + closedSet: false, + directionSensitive: true, + openWildcard: false, + }); + + const result = await getCommandCompletion("play ", "forward", context); + expect(result).toBeDefined(); + expect(result.directionSensitive).toBe(true); + }); + + it("propagates closedSet from cache through request handler", async () => { + patchCacheCompletion({ + completions: ["by", "from"], + matchedPrefixLength: 10, + closedSet: true, + openWildcard: true, + }); + + const result = await getCommandCompletion( + "play hello", + "forward", + context, + ); + expect(result).toBeDefined(); + expect(result.closedSet).toBe(true); + }); +}); diff --git a/ts/packages/dispatcher/types/src/dispatcher.ts b/ts/packages/dispatcher/types/src/dispatcher.ts index 4652e334f..0b3fc3c26 100644 --- a/ts/packages/dispatcher/types/src/dispatcher.ts +++ b/ts/packages/dispatcher/types/src/dispatcher.ts @@ -94,6 +94,12 @@ export type CommandCompletionResult = { // direction. When false, the caller can skip re-fetching on // direction change. directionSensitive: boolean; + // True when the completions are offered at a position where a + // wildcard was finalized at end-of-input. The wildcard's extent + // is ambiguous — the user may still be typing within it — so the + // caller should allow the anchor to slide forward on further input + // rather than re-fetching or giving up. + openWildcard: boolean; }; export type AppAgentStatus = { diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts index 15d3a6a6c..79943d618 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/shell/src/renderer/src/partialCompletionSession.ts @@ -78,6 +78,11 @@ export class PartialCompletionSession { private closedSet: boolean = false; // True when completions differ between forward and backward. private directionSensitive: boolean = false; + // True when the completions are offered at a sliding wildcard + // boundary. When set, the shell slides the anchor forward on + // further input instead of re-fetching or giving up, and + // re-shows the menu at every word boundary. + private openWildcard: boolean = false; // Direction used for the last fetch. private lastDirection: CompletionDirection = "forward"; @@ -283,6 +288,22 @@ export class PartialCompletionSession { // 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 openWildcard is set, 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 (this.openWildcard) { + debug( + `Partial completion anchor slide (A3): '${anchor}' → '${input}' (openWildcard)`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } debug( `Partial completion re-fetch: non-separator after anchor (mode='${sepMode}', rawPrefix='${rawPrefix}')`, ); @@ -346,7 +367,21 @@ export class PartialCompletionSession { // closedSet=false → the set is NOT closed; the user may have // typed something valid that wasn't loaded, so // re-fetch with the longer input (open-set discovery). + // + // Special case: when openWildcard is set and the trie is empty, + // the user is still typing within the wildcard. Slide the anchor + // 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 && this.openWildcard) { + debug( + `Partial completion anchor slide (C6): '${anchor}' → '${input}' (openWildcard)`, + ); + this.anchor = input; + this.menu.hide(); + return true; + } const reuse = closedSet || active; debug( `Partial completion ${reuse ? "reuse" : "re-fetch"}: closedSet=${closedSet}, menuActive=${active}`, @@ -384,6 +419,7 @@ export class PartialCompletionSession { this.separatorMode = result.separatorMode ?? "space"; this.closedSet = result.closedSet; this.directionSensitive = result.directionSensitive; + this.openWildcard = result.openWildcard; this.lastDirection = direction; const completions = toMenuItems(result.completions); diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts index 5a1800dc9..4fd9811a5 100644 --- a/ts/packages/shell/test/partialCompletion/helpers.ts +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -65,6 +65,7 @@ export function makeDispatcher( separatorMode: undefined, closedSet: true, directionSensitive: false, + openWildcard: false, }, ): MockDispatcher { return { @@ -88,6 +89,7 @@ export function makeCompletionResult( completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, ...opts, }; } diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts index 28c4cb85f..e8c429d7b 100644 --- a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts +++ b/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts @@ -45,6 +45,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group1, group2], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -77,6 +78,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -106,6 +108,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -127,6 +130,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [{ name: "empty", completions: [] }], closedSet: false, directionSensitive: false, + openWildcard: false, }; const dispatcher = makeDispatcher(result); const session = new PartialCompletionSession(menu, dispatcher); @@ -152,6 +156,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -186,6 +191,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [group], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); @@ -216,6 +222,7 @@ describe("PartialCompletionSession — result processing", () => { completions: [sortedGroup, unsortedGroup], closedSet: false, directionSensitive: false, + openWildcard: false, separatorMode: "none", }; const dispatcher = makeDispatcher(result); diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts index 0ab79a49e..723a162ef 100644 --- a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts +++ b/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts @@ -68,6 +68,7 @@ function makeSequentialDispatcher( completions: [], closedSet: true, directionSensitive: false, + openWildcard: false, }); return { getCommandCompletion: fn }; } diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts index b3173c4b9..6246d7f19 100644 --- a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts +++ b/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts @@ -317,3 +317,111 @@ describe("PartialCompletionSession — state transitions", () => { expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); }); }); + +describe("PartialCompletionSession — openWildcard anchor sliding", () => { + test("openWildcard=true: non-separator after anchor slides anchor instead of re-fetching", async () => { + const menu = makeMenu(); + // Grammar: play $(track) by $(artist) + // User typed "play my fav" → grammar returns "by" at position 11 + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); // → ACTIVE, anchor="play my fav" + + // User keeps typing the track name — non-separator char "o" + // Without openWildcard, this would trigger A3 re-fetch. + // With openWildcard, the anchor slides forward — no re-fetch. + session.update("play my favo", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + session.update("play my favorite", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); + + test("openWildcard=true: separator after slide shows completions from trie", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); + + // Slide through non-separator chars + session.update("play my favorite", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + + // Now type a space — separator present, completionPrefix="" + // Trie has "by", so the menu should show it. + session.update("play my favorite ", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + expect(menu.updatePrefix).toHaveBeenCalled(); + }); + + test("openWildcard=true: typing the keyword triggers B4 unique match → re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["by"], 11, { + closedSet: true, + openWildcard: true, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play my fav", getPos); + await Promise.resolve(); + + // Type separator + "by" → should match the trie entry exactly + session.update("play my fav by", getPos); + // "by" is uniquely satisfied → B4 triggers re-fetch for next level + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("openWildcard=false: non-separator after anchor triggers normal re-fetch", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["song"], 4, { + closedSet: true, + openWildcard: false, + separatorMode: "spacePunctuation", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play", getPos); + await Promise.resolve(); + + // Without openWildcard, non-separator char triggers A3 re-fetch + session.update("playx", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(2); + }); + + test("openWildcard=true with optional separator: C6 slide when trie empty", async () => { + const menu = makeMenu(); + const result = makeCompletionResult(["next"], 8, { + closedSet: true, + openWildcard: true, + separatorMode: "optional", + }); + const dispatcher = makeDispatcher(result); + const session = new PartialCompletionSession(menu, dispatcher); + + session.update("play foo", getPos); + await Promise.resolve(); + + // With optional separator, raw prefix goes straight to trie. + // "bar" doesn't match "next" → trie empty → C6 + // openWildcard=true → slide anchor, no re-fetch. + session.update("play foobar", getPos); + expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(1); + }); +});