Skip to content
41 changes: 39 additions & 2 deletions ts/docs/architecture/completion.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand Down Expand Up @@ -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.

---

Expand Down Expand Up @@ -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) |

Expand Down Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions ts/packages/actionGrammar/src/grammarMatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand All @@ -1423,6 +1436,7 @@ export function matchGrammarCompletion(
separatorMode = undefined;
closedSet = true;
directionSensitive = false;
openWildcard = false;
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1714,6 +1737,7 @@ export function matchGrammarCompletion(
separatorMode,
closedSet,
directionSensitive,
openWildcard,
};
debugCompletion(`Completed. ${JSON.stringify(result)}`);
return result;
Expand Down
7 changes: 6 additions & 1 deletion ts/packages/actionGrammar/src/nfaCompletion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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);
});
});

Expand Down Expand Up @@ -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);
});
});

Expand Down
6 changes: 6 additions & 0 deletions ts/packages/agentSdk/src/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions ts/packages/cache/src/cache/grammarStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down Expand Up @@ -367,6 +368,7 @@ export class GrammarStoreImpl implements GrammarStore {
separatorMode = undefined;
closedSet = undefined;
directionSensitive = false;
openWildcard = false;
}
if (partialPrefixLength === matchedPrefixLength) {
completions.push(...partial.completions);
Expand All @@ -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
Expand Down Expand Up @@ -418,6 +423,7 @@ export class GrammarStoreImpl implements GrammarStore {
separatorMode,
closedSet,
directionSensitive,
openWildcard,
};
}
}
13 changes: 13 additions & 0 deletions ts/packages/cache/src/constructions/constructionCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
};
}

Expand Down
10 changes: 10 additions & 0 deletions ts/packages/dispatcher/dispatcher/src/command/completion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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}`,
);
Expand All @@ -496,6 +498,7 @@ async function getCommandParameterCompletion(
params.nextArgs.length > 0,
),
directionSensitive: target.directionSensitive || directionSensitive,
openWildcard,
};
}

Expand All @@ -514,6 +517,7 @@ async function completeDescriptor(
separatorMode: SeparatorMode | undefined;
closedSet: boolean;
directionSensitive: boolean;
openWildcard: boolean;
}> {
const completions: CompletionGroup[] = [];
let separatorMode: SeparatorMode | undefined;
Expand Down Expand Up @@ -555,6 +559,7 @@ async function completeDescriptor(
separatorMode,
closedSet: true,
directionSensitive: false,
openWildcard: false,
};
}

Expand All @@ -568,6 +573,7 @@ async function completeDescriptor(
),
closedSet: parameterCompletions.closedSet,
directionSensitive: parameterCompletions.directionSensitive,
openWildcard: parameterCompletions.openWildcard,
};
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -803,6 +811,7 @@ export async function getCommandCompletion(
separatorMode,
closedSet,
directionSensitive,
openWildcard,
};

debug(`Command completion result:`, completionResult);
Expand All @@ -817,6 +826,7 @@ export async function getCommandCompletion(
separatorMode: undefined,
closedSet: false,
directionSensitive: false,
openWildcard: false,
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading