Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
e378eb8
completion
curtisman Mar 5, 2026
468ab7a
name
curtisman Mar 5, 2026
0dcf934
fix dependency
curtisman Mar 5, 2026
c83bac9
Fix matchGrammarCompletion: candidate filtering, needsSeparator seman…
curtisman Mar 5, 2026
cd6f224
Fix command completion to set needsSeparator like grammar matcher
curtisman Mar 6, 2026
f6611a0
Merge remote-tracking branch 'origin/main' into completion
curtisman Mar 6, 2026
080000a
Re-fetch completions when prefix uniquely satisfies a menu entry
curtisman Mar 7, 2026
90923de
Improve needsSeparator docs and add clean command to CLAUDE.md
curtisman Mar 7, 2026
143c435
Refactor command completion: contract, bug fixes, and documentation
curtisman Mar 7, 2026
5ff8dfe
Decouple agent name completions from fallback agent table
curtisman Mar 7, 2026
26a5f00
Fix completion startIndex calculation using parse-position tracking
curtisman Mar 8, 2026
c61b869
Replace needsSeparator boolean with SeparatorMode enum
curtisman Mar 8, 2026
92398f8
Add remainderLength tests and back startIndex over whitespace
curtisman Mar 8, 2026
ae7944c
Fix completion startIndex for lastCompletableParam and make whitespac…
curtisman Mar 9, 2026
5c7b058
fix: compute grammar prefixLength from token start, not tokenBoundary
curtisman Mar 9, 2026
630f8c2
Refactor matchGrammarCompletion to eagerly filter candidates
curtisman Mar 9, 2026
b76535f
Add minPrefixLength to matchGrammarCompletion; enforce longest-prefix…
curtisman Mar 9, 2026
4f780d3
Fix case-sensitive uniquelySatisfied check in SearchMenu.updatePrefix
curtisman Mar 10, 2026
1702ad5
Merge remote-tracking branch 'origin/main' into completion
curtisman Mar 10, 2026
409efed
lock file
curtisman Mar 10, 2026
db67aa9
Remove noCompletion field from PartialCompletionSession
curtisman Mar 10, 2026
56a7c86
Fix type: use CompiledSpacingMode in candidateSeparatorMode and merge…
curtisman Mar 10, 2026
5e8db82
refactor: initialize tokenStartIndex to remainderIndex in completion
curtisman Mar 10, 2026
dfa48cf
perf: defer completion computation until maxPrefixLength check
curtisman Mar 10, 2026
e128a2d
Fix partial completion not refetching after search menu selection
curtisman Mar 10, 2026
c79165d
Fix debug log label in grammar completion matching
curtisman Mar 10, 2026
90743c1
Refactor: move prefixLength and separatorMode from CompletionGroup to…
curtisman Mar 10, 2026
e5cd8f7
Move completion fields from SDK ParsedCommandParams to dispatcher-int…
curtisman Mar 10, 2026
5be29a8
Add complete flag for exhaustive completion signaling
curtisman Mar 10, 2026
3159471
Consolidate one-word-at-a-time completion into tryPartialStringMatch
curtisman Mar 10, 2026
6ec1747
Rename complete property to closedSet across completion pipeline
curtisman Mar 11, 2026
a1ba7a7
Fix comment/implementation inconsistencies and unclear terminology in…
curtisman Mar 11, 2026
4853f80
Fix test mocks to match documented getCommandCompletion contract
curtisman Mar 11, 2026
b191d75
Rename 'current' to 'anchor' and 'prefix' to 'completionPrefix' for c…
curtisman Mar 11, 2026
6ff503a
Categorize re-fetch triggers into invalidation, navigation, and disco…
curtisman Mar 11, 2026
5842273
Replace mock SearchMenu with real trie-backed TestSearchMenu in tests
curtisman Mar 11, 2026
e479c54
Extract SearchMenuBase to unify SearchMenu and TestSearchMenu
curtisman Mar 11, 2026
4aaf278
Add reuseSession tracing and preserve session state on hide()
curtisman Mar 11, 2026
01fc440
Fix commitMode=explicit bypass when closedSet=false on unique match
curtisman Mar 12, 2026
52d4452
Fix spacePunctuation separator stripping and add test coverage
curtisman Mar 12, 2026
4e40064
Reorganize partialCompletion tests into themed files
curtisman Mar 12, 2026
54635b0
Move mergeSeparatorMode and getContentForType to SDK helper modules
curtisman Mar 12, 2026
10563b0
Simplify completion pipeline: unify SeparatorMode, rename prefixLengt…
curtisman Mar 12, 2026
5bd9656
Fix fallback startIndex bugs in getCommandParameterCompletion
curtisman Mar 12, 2026
d82b4cc
Refactor: extract resolveCompletionTarget helper from getCommandParam…
curtisman Mar 12, 2026
462dd5f
Document spec exceptions for non-string params without trailing space
curtisman Mar 12, 2026
5ca59f9
Make resolveCompletionTarget purely decisional
curtisman Mar 12, 2026
ca0ab0c
Improve construction cache completion: track prefix length, separator…
curtisman Mar 13, 2026
48f50c4
Fix wildcard completion in construction matching
curtisman Mar 13, 2026
21591fe
Fix prettier formatting in remainder.spec.ts
curtisman Mar 13, 2026
db24cb4
Fix naming, terminology, and comment/implementation inconsistencies
curtisman Mar 13, 2026
3bc592a
Add jest config
curtisman Mar 13, 2026
eee1e61
Simplify completion e2e: extract helpers, deduplicate, clarify types
curtisman Mar 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ts/.vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"cSpell.words": [
"aiclient",
"AUTOINCREMENT",
"behaviour",
"Chunker",
"Exif",
"exifreader",
Expand Down
5 changes: 4 additions & 1 deletion ts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,12 @@ This is a **pnpm monorepo** rooted at `ts/`. All commands run from the `ts/` dir
```bash
# Install & build
pnpm i
pnpm run build # Uses fluid-build to build all packages
pnpm run build # Uses fluid-build to build all packages
pnpm run build:shell # Build only the shell app and its dependencies

# Clean
pnpm run clean # works at the root and per package

# Test
pnpm run test:local # All unit tests (*.spec.ts) across packages
pnpm run test:live # Integration tests (*.test.ts) — requires API keys
Expand Down
2 changes: 1 addition & 1 deletion ts/packages/actionGrammar/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ npm run test:integration
| --------------------------- | ------------------------------------------------------------------------------------- |
| `action-schema` (workspace) | Reading `.pas.json` schema files for checked-variable enrichment |
| `debug` | Debug logging (`typeagent:grammar:*`, `typeagent:nfa:*`, `typeagent:actionGrammar:*`) |
| `regexp.escape` | Safe regex escaping for the legacy matcher |
| `regexp.escape` | Safe regex escaping for the simple recursive backtracking matcher |

## Trademarks

Expand Down
457 changes: 414 additions & 43 deletions ts/packages/actionGrammar/src/grammarMatcher.ts

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ts/packages/actionGrammar/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export {
GrammarMatchResult,
matchGrammarCompletion,
GrammarCompletionResult,
needsSeparatorInAutoMode,
} from "./grammarMatcher.js";

// Entity system
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { loadGrammarRules } from "../src/grammarLoader.js";
import { matchGrammarCompletion } from "../src/grammarMatcher.js";

describe("Grammar Completion - all alternatives after longest match", () => {
// After the longest fully matched prefix, ALL valid next-part
// completions are reported regardless of trailing partial text.
// The caller is responsible for filtering by the trailing text.

describe("alternatives preserved with partial trailing text", () => {
const g = [
`<Start> = $(a:<Verb>) $(b:<Object>) $(c:<Modifier>) -> { a, b, c };`,
`<Verb> = play -> "play";`,
`<Object> = rock -> "rock";`,
`<Modifier> = music -> "music";`,
`<Modifier> = hard -> "hard";`,
`<Modifier> = loud -> "loud";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

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);
});

it("with trailing space: all alternatives are still offered", () => {
const result = matchGrammarCompletion(grammar, "play rock ");
expect(result.completions.sort()).toEqual([
"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);
});

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);
});

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);
});
});

describe("inline single-part alternatives preserved", () => {
const g = [`<Start> = go (north | south | east | west) -> true;`].join(
"\n",
);
const grammar = loadGrammarRules("test.grammar", g);

it("all directions offered without partial text", () => {
const result = matchGrammarCompletion(grammar, "go");
expect(result.completions.sort()).toEqual([
"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);
});

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);
});
});

describe("all alternatives after consumed prefix", () => {
const g = [
`<Start> = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> = open -> "open";`,
`<B> = file -> "file";`,
`<B> = folder -> "folder";`,
`<B> = finder -> "finder";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("'open f': all alternatives offered", () => {
const result = matchGrammarCompletion(grammar, "open f");
expect(result.completions.sort()).toEqual([
"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);
});
});

describe("no false completions when nothing consumed", () => {
// When state.index === 0 (no prefix consumed), we should NOT
// report unrelated string parts — only partial prefix matches.
const g = [
`<Start> = play $(g:<Genre>) -> { genre: g };`,
`<Genre> = rock -> "rock";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("all first parts offered for unrelated input", () => {
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);
});

it("partial prefix at start still works", () => {
const result = matchGrammarCompletion(grammar, "pl");
expect(result.completions).toContain("play");
});
});

describe("separatorMode in Category 3b", () => {
// Category 3b: finalizeState failed, trailing non-separator text
// remains. separatorMode indicates whether a separator is needed
// between matchedPrefixLength and the completion text.

describe("Latin grammar (auto spacing)", () => {
const g = [
`<Start> = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> = play -> "a";`,
`<B> = music -> "b";`,
`<B> = midi -> "b2";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("reports separatorMode after consumed Latin prefix with trailing text", () => {
// "play x" → consumed "play" (4 chars), trailing "x" fails.
// Completion "music"/"midi" at matchedPrefixLength=4.
// 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");
});

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");
});
});

describe("CJK grammar (auto spacing)", () => {
const g = [
`<Start> [spacing=auto] = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> [spacing=auto] = 再生 -> "a";`,
`<B> [spacing=auto] = 音楽 -> "b";`,
`<B> [spacing=auto] = 映画 -> "b2";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("reports optional separatorMode for CJK → CJK", () => {
// "再生x" → consumed "再生" (2 chars), trailing "x" fails.
// 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("optional");
});
});

describe("nothing consumed (index=0)", () => {
const g = [
`<Start> = play $(g:<Genre>) -> { genre: g };`,
`<Genre> = rock -> "rock";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("reports optional separatorMode when nothing consumed", () => {
// "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("optional");
});
});

describe("spacing=required", () => {
const g = [
`<Start> [spacing=required] = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> [spacing=required] = play -> "a";`,
`<B> [spacing=required] = music -> "b";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

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");
});
});

describe("spacing=optional", () => {
const g = [
`<Start> [spacing=optional] = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> [spacing=optional] = play -> "a";`,
`<B> [spacing=optional] = music -> "b";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("reports optional 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");
});
});

describe("mixed scripts (Latin → CJK)", () => {
const g = [
`<Start> [spacing=auto] = $(a:<A>) $(b:<B>) -> { a, b };`,
`<A> [spacing=auto] = play -> "a";`,
`<B> [spacing=auto] = 音楽 -> "b";`,
].join("\n");
const grammar = loadGrammarRules("test.grammar", g);

it("reports optional separatorMode for Latin → CJK", () => {
// 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("optional");
});
});
});
});
Loading