Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 15 additions & 1 deletion ts/packages/shell/src/renderer/src/partial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,27 @@ export class PartialCompletion {
return false;
}
if (event.key === "Escape") {
this.searchMenu.hide();
this.explicitHide();
event.preventDefault();
return true;
}
return this.searchMenu.handleSpecialKeys(event);
}

private explicitHide(): void {
const input = this.getCurrentInputForCompletion();
const direction: CompletionDirection =
input.length < this.previousInput.length &&
this.previousInput.startsWith(input)
? "backward"
: "forward";
this.session.explicitHide(
input,
(prefix) => this.getSearchMenuPosition(prefix),
direction,
);
}

public handleMouseWheel(event: WheelEvent) {
this.searchMenu.handleMouseWheel(event.deltaY);
}
Expand Down
57 changes: 57 additions & 0 deletions ts/packages/shell/src/renderer/src/partialCompletionSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,12 @@ export class PartialCompletionSession {
// The in-flight completion request, or undefined when settled.
private completionP: Promise<CommandCompletionResult> | undefined;

// Set when the user explicitly closes the menu (e.g. Escape).
// startNewSession uses this to suppress reopening if the refetch returns
// the same anchor β€” meaning the completions are unchanged and the user
// already dismissed them.
private explicitCloseAnchor: string | undefined = undefined;

constructor(
private readonly menu: ISearchMenu,
private readonly dispatcher: ICompletionDispatcher,
Expand Down Expand Up @@ -146,6 +152,41 @@ export class PartialCompletionSession {
public resetToIdle(): void {
this.anchor = undefined;
this.completionP = undefined;
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
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"
) {
return;
}

// Save anchor so startNewSession can compare after the result arrives.
this.explicitCloseAnchor = this.anchor;
this.startNewSession(input, getPosition, direction);
}

// Returns the text typed after the anchor, or undefined when
Expand Down Expand Up @@ -489,6 +530,21 @@ export class PartialCompletionSession {

this.menu.setChoices(completions);

// If triggered by an explicit close, only reopen when the
// anchor advanced. Same anchor means the same completions at
// the same position β€” the user already dismissed them.
const explicitCloseAnchor = this.explicitCloseAnchor;
this.explicitCloseAnchor = undefined;
if (
explicitCloseAnchor !== undefined &&
partial === explicitCloseAnchor
) {
debug(
`Partial completion explicit-hide: anchor unchanged ('${partial}'), suppressing reopen`,
);
return;
}

// Re-run update with captured input to show the menu (or defer
// if the separator has not been typed yet).
this.reuseSession(input, getPosition, direction);
Expand All @@ -499,6 +555,7 @@ export class PartialCompletionSession {
// anchor so that identical input reuses the session (no
// re-fetch) while diverged input still triggers a new fetch.
this.completionP = undefined;
this.explicitCloseAnchor = undefined;
});
}
}
Expand Down
131 changes: 131 additions & 0 deletions ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,4 +948,135 @@ describe("PartialCompletionSession β€” grammar e2e with mocked entities", () =>
expect(session.getCompletionPrefix("pla")).toBeUndefined();
});
});

// ── explicitHide() β€” explicit close and conditional refetch ──────────

describe("explicitHide() β€” explicit close with conditional refetch", () => {
test("no refetch when noMatchPolicy=accept (keyword level, closed set)", async () => {
const menu = makeMenu();
const dispatcher = makeGrammarDispatcher(
musicGrammar,
musicEntities,
);
const session = new PartialCompletionSession(menu, dispatcher);

// Establish keyword completions at anchor=""; closedSet=true β†’ accept.
session.update("", getPos);
await flush();

// Narrow to "p…" via trie β€” no re-fetch.
session.update("p", getPos);
expect(menu.isActive()).toBe(true);

const fetchCountBefore =
dispatcher.getCommandCompletion.mock.calls.length;

// Explicit close: input "p" β‰  anchor "" but noMatchPolicy=accept β†’ skip refetch.
session.explicitHide("p", getPos, "forward");

expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(
fetchCountBefore,
);
expect(menu.hide).toHaveBeenCalled();
});

test("no refetch when input equals anchor", async () => {
const menu = makeMenu();
const dispatcher = makeGrammarDispatcher(
musicGrammar,
musicEntities,
);
const session = new PartialCompletionSession(menu, dispatcher);

// Establish anchor = "".
session.update("", getPos);
await flush();

const fetchCountBefore =
dispatcher.getCommandCompletion.mock.calls.length;

// input === anchor β†’ skip refetch.
session.explicitHide("", getPos, "forward");

expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(
fetchCountBefore,
);
});

test("refetch triggered; same anchor β†’ reopen suppressed", async () => {
const menu = makeMenu();
const dispatcher = makeGrammarDispatcher(
musicGrammar,
musicEntities,
);
const session = new PartialCompletionSession(menu, dispatcher);

// Navigate to entity completions at anchor="play".
session.update("", getPos);
await flush();
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");
await flush();

// Refetch was issued with the full current input.
expect(dispatcher.getCommandCompletion).toHaveBeenCalledTimes(
fetchCountBefore + 1,
);
expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith(
"play sha",
"forward",
);
// Same anchor β†’ reopen suppressed: menu stays hidden.
expect(menu.isActive()).toBe(false);
});

test("refetch triggered; anchor advances β†’ session moves to next level", async () => {
const menu = makeMenu();
const dispatcher = makeGrammarDispatcher(
musicGrammar,
musicEntities,
);
const session = new PartialCompletionSession(menu, dispatcher);

// Navigate to entity completions at anchor="play".
session.update("", getPos);
await flush();
session.update("play", getPos);
await flush();
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.
session.explicitHide("play Shake It Off", getPos, "forward");
await flush();

// Refetch was issued with the full entity string.
expect(dispatcher.getCommandCompletion).toHaveBeenLastCalledWith(
"play Shake It Off",
"forward",
);

// Next-level completions include the "by" keyword.
expect(menu.setChoices).toHaveBeenLastCalledWith(
expect.arrayContaining([
expect.objectContaining({ matchText: "by" }),
]),
);

// Typing the separator at the new anchor reveals the "by" completion.
session.update("play Shake It Off ", getPos);
expect(menu.isActive()).toBe(true);
});
});
});
Loading