diff --git a/ts/packages/cli/src/cliSearchMenu.ts b/ts/packages/cli/src/cliSearchMenu.ts new file mode 100644 index 0000000000..7ac79fa70b --- /dev/null +++ b/ts/packages/cli/src/cliSearchMenu.ts @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { + SearchMenuBase, + SearchMenuItem, + SearchMenuPosition, +} from "agent-dispatcher/helpers/completion"; + +// CLI adapter for ISearchMenu. Extends SearchMenuBase (which provides +// TST-based prefix matching) and captures the filtered items so that +// questionWithCompletion can read them for ghost-text display. +// +// Architecture: docs/architecture/completion.md — §CLI integration +export class CliSearchMenu extends SearchMenuBase { + private currentItems: SearchMenuItem[] = []; + private readonly onUpdate: () => void; + + constructor(onUpdate: () => void) { + super(); + this.onUpdate = onUpdate; + } + + protected override onShow( + _position: SearchMenuPosition, + _prefix: string, + items: SearchMenuItem[], + ): void { + this.currentItems = items; + this.onUpdate(); + } + + protected override onHide(): void { + this.currentItems = []; + this.onUpdate(); + } + + /** Returns the items currently visible in the menu (trie-filtered). */ + public getItems(): SearchMenuItem[] { + return this.currentItems; + } +} diff --git a/ts/packages/cli/src/commands/connect.ts b/ts/packages/cli/src/commands/connect.ts index 04ed591020..fbe15048e3 100644 --- a/ts/packages/cli/src/commands/connect.ts +++ b/ts/packages/cli/src/commands/connect.ts @@ -9,7 +9,6 @@ import { replayDisplayHistory, withEnhancedConsoleClientIO, } from "../enhancedConsole.js"; -import { isSlashCommand, getSlashCompletions } from "../slashCommands.js"; import { connectAgentServer, ensureAgentServer, @@ -66,59 +65,6 @@ function promptYesNo(question: string): Promise { }); } -type CompletionData = { - allCompletions: string[]; - filterStartIndex: number; - prefix: string; -}; - -async function getCompletionsData( - line: string, - dispatcher: Dispatcher, -): Promise { - try { - if (isSlashCommand(line)) { - const completions = getSlashCompletions(line); - if (completions.length === 0) return null; - return { - allCompletions: completions, - filterStartIndex: 0, - prefix: "", - }; - } - const direction = "forward" as const; - const result = await dispatcher.getCommandCompletion(line, direction); - if (result.completions.length === 0) { - return null; - } - - const allCompletions: string[] = []; - for (const group of result.completions) { - for (const completion of group.completions) { - allCompletions.push(completion); - } - } - - const filterStartIndex = result.startIndex; - const prefix = line.substring(0, filterStartIndex); - - const needsSep = result.completions.some( - (g) => - g.separatorMode === "space" || - g.separatorMode === "spacePunctuation", - ); - const separator = needsSep ? " " : ""; - - return { - allCompletions, - filterStartIndex, - prefix: prefix + separator, - }; - } catch (e) { - return null; - } -} - export default class Connect extends Command { static description = "Connect to the agent server in interactive mode. Defaults to the 'CLI' session, or specify --session to join a specific one."; @@ -291,7 +237,7 @@ export default class Connect extends Command { dispatcher.processCommand(command), dispatcher, undefined, - (line: string) => getCompletionsData(line, dispatcher), + dispatcher, // session-based completions dispatcher, ); } finally { diff --git a/ts/packages/cli/src/commands/interactive.ts b/ts/packages/cli/src/commands/interactive.ts index 8ad5f71c51..c9881e6cb2 100644 --- a/ts/packages/cli/src/commands/interactive.ts +++ b/ts/packages/cli/src/commands/interactive.ts @@ -21,7 +21,6 @@ import { processCommandsEnhanced, withEnhancedConsoleClientIO, } from "../enhancedConsole.js"; -import { isSlashCommand, getSlashCompletions } from "../slashCommands.js"; import { getStatusSummary } from "agent-dispatcher/helpers/status"; import { getFsStorageProvider } from "dispatcher-node-providers"; @@ -32,73 +31,6 @@ const { schemaNames } = await getAllActionConfigProvider( defaultAppAgentProviders, ); -/** - * Get completions for the current input line using dispatcher's command completion API - */ -// Return completion data including where filtering starts -type CompletionData = { - allCompletions: string[]; // All available completions (just the completion text) - filterStartIndex: number; // Where user typing should filter (after the space/trigger) - prefix: string; // Fixed prefix before completions -}; - -// Architecture: docs/architecture/completion.md — §CLI integration -async function getCompletionsData( - line: string, - dispatcher: Dispatcher, -): Promise { - try { - // Handle slash command completions - if (isSlashCommand(line)) { - const completions = getSlashCompletions(line); - if (completions.length === 0) return null; - return { - allCompletions: completions, - filterStartIndex: 0, - prefix: "", - }; - } - // Send the full input to the backend; the grammar matcher reports - // how much it consumed (matchedPrefixLength → startIndex) so the - // CLI need not split on spaces to find token boundaries. - // CLI tab-completion is always a forward action. - const direction = "forward" as const; - const result = await dispatcher.getCommandCompletion(line, direction); - if (result.completions.length === 0) { - return null; - } - - // Extract just the completion strings - const allCompletions: string[] = []; - for (const group of result.completions) { - for (const completion of group.completions) { - allCompletions.push(completion); - } - } - - const filterStartIndex = result.startIndex; - const prefix = line.substring(0, filterStartIndex); - - // 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 needsSep = result.completions.some( - (g) => - g.separatorMode === "space" || - g.separatorMode === "spacePunctuation", - ); - const separator = needsSep ? " " : ""; - - return { - allCompletions, - filterStartIndex, - prefix: prefix + separator, - }; - } catch (e) { - return null; - } -} - export default class Interactive extends Command { static description = "Interactive mode"; static flags = { @@ -217,7 +149,7 @@ export default class Interactive extends Command { dispatcher.processCommand(command), dispatcher, undefined, // inputs - (line: string) => getCompletionsData(line, dispatcher), + dispatcher, // session-based completions dispatcher, ); } finally { diff --git a/ts/packages/cli/src/enhancedConsole.ts b/ts/packages/cli/src/enhancedConsole.ts index 9815755dc3..bc2e3f8fd8 100644 --- a/ts/packages/cli/src/enhancedConsole.ts +++ b/ts/packages/cli/src/enhancedConsole.ts @@ -26,6 +26,10 @@ import type { IAgentMessage, TemplateEditConfig, } from "agent-dispatcher"; +import type { ICompletionDispatcher } from "agent-dispatcher/helpers/completion"; +import { PartialCompletionSession } from "agent-dispatcher/helpers/completion"; +import type { SearchMenuPosition } from "agent-dispatcher/helpers/completion"; +import { CliSearchMenu } from "./cliSearchMenu.js"; import chalk from "chalk"; import fs from "fs"; import path from "path"; @@ -39,6 +43,7 @@ import { isSlashCommand, handleSlashCommand, getVerboseIndicator, + getSlashCompletions, } from "./slashCommands.js"; import { setSpinnerAccessor, @@ -46,6 +51,10 @@ import { PromptRenderer, } from "./debugInterceptor.js"; +type CompletionSource = + | ((line: string, context: T) => Promise) + | ICompletionDispatcher; + // Track current processing state let currentSpinner: EnhancedSpinner | null = null; @@ -1033,8 +1042,9 @@ function formatDisplayContent(content: string | DisplayContent): string { */ async function questionWithCompletion( message: string, - getCompletions: (input: string) => Promise, + getCompletions: ((input: string) => Promise) | undefined, history: string[] = [], + completionDispatcher?: ICompletionDispatcher, ): Promise { return new Promise((resolve) => { let input = ""; @@ -1047,6 +1057,11 @@ async function questionWithCompletion( let filterStartIndex = -1; // Where filtering begins let completionPrefix = ""; // Fixed prefix before completions let updatingCompletions = false; + // Session-mode state (used when completionDispatcher is provided) + let session: PartialCompletionSession | undefined; + let menu: CliSearchMenu | undefined; + // Position callback for session APIs (CLI has no spatial positioning). + const getPos = (): SearchMenuPosition => ({ left: 0, bottom: 0 }); const stdin = process.stdin; const stdout = process.stdout; @@ -1096,6 +1111,38 @@ async function questionWithCompletion( const EXTRA_ROWS = 3; // separator + bottom rule + hint const render = () => { + // Session mode: recompute completions from session/menu state each frame. + if (completionDispatcher) { + if (isSlashCommand(input)) { + const completions = getSlashCompletions(input); + filteredCompletions = completions; + filterStartIndex = 0; + completionPrefix = ""; + } else if (session !== undefined && menu !== undefined) { + const sessionItems = menu.getItems(); + const rawPrefix = session.getCompletionPrefix(input); + if (sessionItems.length > 0 && rawPrefix !== undefined) { + const anchorIndex = input.length - rawPrefix.length; + filteredCompletions = sessionItems.map( + (i) => i.selectedText, + ); + filterStartIndex = anchorIndex; + completionPrefix = input.substring(0, anchorIndex); + } else { + filteredCompletions = []; + filterStartIndex = -1; + completionPrefix = ""; + } + } else { + filteredCompletions = []; + filterStartIndex = -1; + completionPrefix = ""; + } + if (completionIndex >= filteredCompletions.length) { + completionIndex = 0; + } + } + const promptText = chalk.cyanBright(message); const width = process.stdout.columns || 80; @@ -1169,8 +1216,12 @@ async function questionWithCompletion( prevInputRows = inputRows; }; - // Fetch completions for current input + // Fetch completions for current input (callback mode only). + // In session mode, completions are driven by session.update() + menu callbacks. const updateCompletions = async () => { + if (!getCompletions) { + return; + } if (updatingCompletions) { return; // Skip if already updating } @@ -1201,6 +1252,14 @@ async function questionWithCompletion( }; // Activate the scroll region and draw initial prompt + // Initialize session-based completions after render is defined. + if (completionDispatcher) { + menu = new CliSearchMenu(() => { + completionIndex = 0; + render(); + }); + session = new PartialCompletionSession(menu, completionDispatcher); + } layout.setup(prevInputRows + EXTRA_ROWS); render(); @@ -1279,8 +1338,18 @@ async function questionWithCompletion( } if (data === "\x1b") { - if (filteredCompletions.length > 0) { - // Esc with completions showing — clear completions + if (session !== undefined && menu !== undefined) { + // Session mode: use explicitHide for smart level-shift / refetch + if (filteredCompletions.length > 0) { + session.explicitHide(input, getPos, "forward"); + } else { + // Esc with no completions — clear input + input = ""; + cursorPos = 0; + historyIndex = history.length; + } + } else if (filteredCompletions.length > 0) { + // Callback mode: clear completions allCompletions = []; filteredCompletions = []; filterStartIndex = -1; @@ -1341,6 +1410,9 @@ async function questionWithCompletion( : "") + completion; } + if (session !== undefined) { + session.resetToIdle(); + } // Tear down the scroll region and write the finalized input // into the normal terminal flow so it appears in scrollback. cleanup(); @@ -1370,9 +1442,14 @@ async function questionWithCompletion( : "") + completion; cursorPos = input.length; // Move cursor to end - allCompletions = []; - filteredCompletions = []; - filterStartIndex = -1; + if (session !== undefined) { + // Session mode: reset so next render sees no completions + session.resetToIdle(); + } else { + allCompletions = []; + filteredCompletions = []; + filterStartIndex = -1; + } render(); } } else if (code === 127 || code === 8) { @@ -1381,28 +1458,35 @@ async function questionWithCompletion( input = input.slice(0, cursorPos - 1) + input.slice(cursorPos); cursorPos--; - // Update completions state - if ( - filterStartIndex < 0 || - input.length < filterStartIndex - ) { - filteredCompletions = []; - allCompletions = []; - filterStartIndex = -1; + if (session !== undefined) { + // Session mode: drive the session backward + session.update(input, getPos, "backward"); } else { - filterCompletions(); + // Callback mode: update completions state + if ( + filterStartIndex < 0 || + input.length < filterStartIndex + ) { + filteredCompletions = []; + allCompletions = []; + filterStartIndex = -1; + } else { + filterCompletions(); + } + // Just render - skip async updateCompletions to avoid double render/flash + render(); } - // Just render - skip async updateCompletions to avoid double render/flash - render(); } } else if (code >= 32 && code < 127) { // Printable ASCII character - insert at cursor position input = input.slice(0, cursorPos) + data + input.slice(cursorPos); cursorPos++; - // If typing a space, always fetch new completions (new context) - if (data === " ") { - // Clear completions and render immediately to prevent flashing + if (session !== undefined) { + // Session mode: drive the session forward on every character + session.update(input, getPos, "forward"); + } else if (data === " ") { + // Callback mode: typing a space always fetches new completions filteredCompletions = []; render(); await updateCompletions(); @@ -1666,7 +1750,7 @@ export async function processCommandsEnhanced( processCommand: (request: string, context: T) => Promise, context: T, inputs?: string[], - getCompletions?: (line: string, context: T) => Promise, + completionSource?: CompletionSource, dispatcherForCancel?: Dispatcher, ) { const fs = await import("node:fs"); @@ -1681,7 +1765,7 @@ export async function processCommandsEnhanced( // Only create readline when not using inline completions. // questionWithCompletion manages stdin directly; a readline attached // to the same stdin would consume events and break input. - const rl = getCompletions + const rl = completionSource ? undefined : createInterface({ input: process.stdin, @@ -1706,13 +1790,23 @@ export async function processCommandsEnhanced( ANSI.dim + "─".repeat(width) + ANSI.reset + "\n", ); request = getNextInput(prompt, inputs, promptColor); - } else if (getCompletions) { - // Use inline completion system with scroll region anchored prompt - request = await questionWithCompletion( - promptColor(prompt), - (line: string) => getCompletions(line, context), - history, - ); + } else if (completionSource) { + if (typeof completionSource === "function") { + // Callback mode: legacy path + request = await questionWithCompletion( + promptColor(prompt), + (line: string) => completionSource(line, context), + history, + ); + } else { + // Dispatcher mode: session-based completions + request = await questionWithCompletion( + promptColor(prompt), + undefined, + history, + completionSource, + ); + } } else { process.stdout.write( ANSI.dim + "─".repeat(width) + ANSI.reset + "\n", diff --git a/ts/packages/dispatcher/dispatcher/jest.config.cjs b/ts/packages/dispatcher/dispatcher/jest.config.cjs index f475768a12..995b38f2a0 100644 --- a/ts/packages/dispatcher/dispatcher/jest.config.cjs +++ b/ts/packages/dispatcher/dispatcher/jest.config.cjs @@ -1,4 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -module.exports = require("../../../jest.config.js"); +const base = require("../../../jest.config.js"); +module.exports = { + ...base, + moduleNameMapper: { + ...base.moduleNameMapper, + // Map any relative ../src/ import (at any depth) to the compiled dist/ output. + "^(?:\\.\\./)+src/(.*)$": "/dist/$1", + }, +}; diff --git a/ts/packages/dispatcher/dispatcher/package.json b/ts/packages/dispatcher/dispatcher/package.json index 852d5bf120..8a29cca666 100644 --- a/ts/packages/dispatcher/dispatcher/package.json +++ b/ts/packages/dispatcher/dispatcher/package.json @@ -19,7 +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", + "./helpers/completion": "./dist/helpers/completion/index.js", "./internal": "./dist/internal.js", "./explorer": "./dist/explorer.js" }, @@ -79,6 +79,7 @@ "zod": "^4.1.13" }, "devDependencies": { + "@jest/globals": "^29.7.0", "@types/debug": "^4.1.12", "@types/file-size": "^1.0.3", "@types/html-to-text": "^9.0.4", diff --git a/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts b/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts deleted file mode 100644 index cecc97205d..0000000000 --- a/ts/packages/dispatcher/dispatcher/src/helpers/completion.ts +++ /dev/null @@ -1,9 +0,0 @@ -// 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/src/helpers/completion/index.ts b/ts/packages/dispatcher/dispatcher/src/helpers/completion/index.ts new file mode 100644 index 0000000000..5d7ce7b842 --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/src/helpers/completion/index.ts @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Re-export completion utilities for consumers that import through the dispatcher. + +export { + BaseTSTData, + TST, + SearchMenuPosition, + SearchMenuItem, + SearchMenuBase, + normalizeMatchText, +} from "./searchMenu.js"; +export { + ISearchMenu, + ICompletionDispatcher, + PartialCompletionSession, +} from "./session.js"; diff --git a/ts/packages/shell/src/renderer/src/prefixTree.ts b/ts/packages/dispatcher/dispatcher/src/helpers/completion/searchMenu.ts similarity index 71% rename from ts/packages/shell/src/renderer/src/prefixTree.ts rename to ts/packages/dispatcher/dispatcher/src/helpers/completion/searchMenu.ts index 42f6df81a6..0106ba87be 100644 --- a/ts/packages/shell/src/renderer/src/prefixTree.ts +++ b/ts/packages/dispatcher/dispatcher/src/helpers/completion/searchMenu.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +// ── Prefix tree (ternary search tree) ──────────────────────────────────────── + export class TSTNode { constructor(public c: string) { this.count = 0; @@ -305,3 +307,104 @@ export class TST { } } } + +// ── Search menu types ───────────────────────────────────────────────────────── + +export type SearchMenuPosition = { + left: number; + bottom: number; +}; + +export type SearchMenuItem = { + matchText: string; + emojiChar?: string | undefined; + sortIndex?: number; + selectedText: string; + needQuotes?: boolean | undefined; // When undefined, treated as true by consumers (add quotes if selectedText has spaces). +}; + +// ── Search menu base ────────────────────────────────────────────────────────── + +export function normalizeMatchText(text: string): string { + // Remove diacritical marks, and replace any space characters with normalized ' '. + return text + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") // Remove combining diacritical marks + .replace(/\s/g, " ") + .toLowerCase(); +} + +// Architecture: docs/architecture/completion.md — §7 Shell — Search Menu +export class SearchMenuBase { + private trie: TST = new TST(); + private prefix: string | undefined; + private _active: boolean = false; + + public setChoices(choices: SearchMenuItem[]): void { + this.prefix = undefined; + this.trie.init(); + for (const choice of choices) { + // choices are sorted in priority order so prefer first norm text + const normText = normalizeMatchText(choice.matchText); + if (!this.trie.get(normText)) { + this.trie.insert(normText, choice); + } + } + } + + public numChoices(): number { + return this.trie.size(); + } + + public hasExactMatch(text: string): boolean { + return this.trie.contains(normalizeMatchText(text)); + } + + public updatePrefix(prefix: string, position: SearchMenuPosition): boolean { + if (this.trie.size() === 0) { + return false; + } + + if (this.prefix === prefix && this._active) { + this.onUpdatePosition(position); + return false; + } + + this.prefix = prefix; + const items = this.trie.dataWithPrefix(normalizeMatchText(prefix)); + const uniquelySatisfied = + items.length === 1 && + normalizeMatchText(items[0].matchText) === + normalizeMatchText(prefix); + const showMenu = items.length !== 0 && !uniquelySatisfied; + + if (showMenu) { + this._active = true; + this.onShow(position, prefix, items); + } else { + this.hide(); + } + return uniquelySatisfied; + } + + public hide(): void { + if (this._active) { + this._active = false; + this.onHide(); + } + } + + public isActive(): boolean { + return this._active; + } + + protected onShow( + _position: SearchMenuPosition, + _prefix: string, + _items: SearchMenuItem[], + ): void {} + + protected onUpdatePosition(_position: SearchMenuPosition): void {} + + protected onHide(): void {} +} diff --git a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts b/ts/packages/dispatcher/dispatcher/src/helpers/completion/session.ts similarity index 98% rename from ts/packages/shell/src/renderer/src/partialCompletionSession.ts rename to ts/packages/dispatcher/dispatcher/src/helpers/completion/session.ts index a22e9ce803..a05e4e8f4c 100644 --- a/ts/packages/shell/src/renderer/src/partialCompletionSession.ts +++ b/ts/packages/dispatcher/dispatcher/src/helpers/completion/session.ts @@ -1,22 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { CommandCompletionResult } from "agent-dispatcher"; -import { needsSeparatorInAutoMode } from "agent-dispatcher/helpers/completion"; +import { CommandCompletionResult } from "@typeagent/dispatcher-types"; +import { needsSeparatorInAutoMode } from "action-grammar/completion"; import { AfterWildcard, CompletionDirection, CompletionGroup, SeparatorMode, } from "@typeagent/agent-sdk"; -import { - SearchMenuItem, - SearchMenuPosition, -} from "../../preload/electronTypes.js"; +import { SearchMenuItem, SearchMenuPosition } from "./searchMenu.js"; import registerDebug from "debug"; -const debug = registerDebug("typeagent:shell:partial"); -const debugError = registerDebug("typeagent:shell:partial:error"); +const debug = registerDebug("typeagent:completion:session"); +const debugError = registerDebug("typeagent:completion:session:error"); export interface ISearchMenu { setChoices(choices: SearchMenuItem[]): void; @@ -653,7 +650,7 @@ export class PartialCompletionSession { } } -// ── SepLevel: separator progression model ──────────────────────────────── +// ── SepLevel: separator progression model ──────────────────────────────────── // // Three ordered levels describing what separator characters have been // consumed between anchor and menuAnchorIndex. Each level defines which diff --git a/ts/packages/shell/test/partialCompletion/direction.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/direction.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/direction.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/direction.spec.ts diff --git a/ts/packages/shell/test/partialCompletion/errorHandling.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/errorHandling.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/errorHandling.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/errorHandling.spec.ts diff --git a/ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/grammarE2E.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/grammarE2E.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/grammarE2E.spec.ts diff --git a/ts/packages/dispatcher/dispatcher/test/partialCompletion/helpers.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/helpers.ts new file mode 100644 index 0000000000..f36a29efca --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/partialCompletion/helpers.ts @@ -0,0 +1,132 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { jest, type jest as JestTypes } from "@jest/globals"; +import { + ICompletionDispatcher, + ISearchMenu, + PartialCompletionSession, + SearchMenuBase, + SearchMenuPosition, +} from "../../src/helpers/completion/index.js"; +import { CompletionGroup, SeparatorMode } from "@typeagent/agent-sdk"; +import { CommandCompletionResult } from "@typeagent/dispatcher-types"; + +export { PartialCompletionSession }; +export type { ICompletionDispatcher, ISearchMenu }; +export type { CompletionGroup }; +export type { CommandCompletionResult }; +export type { SearchMenuPosition }; + +type Mocked any> = T & + JestTypes.MockedFunction; + +// Real trie-backed ISearchMenu backed by SearchMenuBase. +// Every method is a jest.fn() wrapping the real implementation so tests can +// assert on call counts and arguments. +export class TestSearchMenu extends SearchMenuBase { + override setChoices: Mocked = jest.fn( + (...args: Parameters) => + super.setChoices(...args), + ) as any; + + override updatePrefix: Mocked = jest.fn( + (prefix: string, position: SearchMenuPosition): boolean => + super.updatePrefix(prefix, position), + ) as any; + + override hasExactMatch: Mocked = jest.fn( + (text: string): boolean => super.hasExactMatch(text), + ) as any; + + override hide: Mocked = jest.fn(() => + super.hide(), + ) as any; + + override isActive: Mocked = jest.fn(() => + super.isActive(), + ) as any; +} + +export function makeMenu(): TestSearchMenu { + return new TestSearchMenu(); +} + +export type MockDispatcher = { + getCommandCompletion: jest.MockedFunction< + ICompletionDispatcher["getCommandCompletion"] + >; +}; + +export function makeDispatcher( + result: CommandCompletionResult = { + startIndex: 0, + completions: [], + closedSet: true, + directionSensitive: false, + afterWildcard: "none", + }, +): MockDispatcher { + return { + getCommandCompletion: jest + .fn() + .mockResolvedValue(result), + }; +} + +export const anyPosition: SearchMenuPosition = { left: 0, bottom: 0 }; +export const getPos = (_prefix: string) => anyPosition; + +export function makeCompletionResult( + completions: string[], + startIndex: number = 0, + opts: Partial & { + separatorMode?: SeparatorMode; + } = {}, +): CommandCompletionResult { + 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, + }; +} + +// Returns the selectedText values from the last setChoices call on a +// TestSearchMenu mock. Avoids repeating the verbose mock.calls pattern. +export function lastSetChoicesItems(menu: TestSearchMenu): string[] { + const calls = menu.setChoices.mock.calls; + return calls[calls.length - 1][0].map( + (i: { selectedText: string }) => i.selectedText, + ); +} diff --git a/ts/packages/shell/test/partialCompletion/publicAPI.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/publicAPI.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/publicAPI.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/publicAPI.spec.ts diff --git a/ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/resultProcessing.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/resultProcessing.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/resultProcessing.spec.ts diff --git a/ts/packages/dispatcher/dispatcher/test/partialCompletion/searchMenuBase.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/searchMenuBase.spec.ts new file mode 100644 index 0000000000..93c71f2d4d --- /dev/null +++ b/ts/packages/dispatcher/dispatcher/test/partialCompletion/searchMenuBase.spec.ts @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it, jest } from "@jest/globals"; +import { + SearchMenuBase, + SearchMenuItem, + SearchMenuPosition, +} from "../../src/helpers/completion/index.js"; + +// Minimal adapter mirroring CliSearchMenu to verify SearchMenuBase behaviour. +class TestAdapter extends SearchMenuBase { + public currentItems: SearchMenuItem[] = []; + public readonly onUpdate: jest.Mock; + + constructor() { + super(); + this.onUpdate = jest.fn(); + } + + protected override onShow( + _position: SearchMenuPosition, + _prefix: string, + items: SearchMenuItem[], + ): void { + this.currentItems = items; + this.onUpdate(); + } + + protected override onHide(): void { + this.currentItems = []; + this.onUpdate(); + } +} + +const pos: SearchMenuPosition = { left: 0, bottom: 0 }; + +function makeItem(text: string): SearchMenuItem { + return { matchText: text, selectedText: text }; +} + +describe("SearchMenuBase adapter", () => { + it("getItems returns matching items after updatePrefix", () => { + const menu = new TestAdapter(); + menu.setChoices([ + makeItem("apple"), + makeItem("apricot"), + makeItem("banana"), + ]); + + menu.updatePrefix("ap", pos); + + expect(menu.currentItems.map((i) => i.selectedText)).toEqual([ + "apple", + "apricot", + ]); + expect(menu.isActive()).toBe(true); + expect(menu.onUpdate).toHaveBeenCalledTimes(1); + }); + + it("getItems is empty after hide", () => { + const menu = new TestAdapter(); + menu.setChoices([makeItem("apple"), makeItem("banana")]); + menu.updatePrefix("a", pos); + + expect(menu.currentItems).toHaveLength(1); + + menu.hide(); + + expect(menu.currentItems).toEqual([]); + expect(menu.isActive()).toBe(false); + // onUpdate called once for show, once for hide + expect(menu.onUpdate).toHaveBeenCalledTimes(2); + }); + + it("updatePrefix hides when no matches", () => { + const menu = new TestAdapter(); + menu.setChoices([makeItem("apple")]); + menu.updatePrefix("a", pos); + expect(menu.isActive()).toBe(true); + + menu.updatePrefix("z", pos); + expect(menu.isActive()).toBe(false); + expect(menu.currentItems).toEqual([]); + }); + + it("updatePrefix hides on exact unique match", () => { + const menu = new TestAdapter(); + menu.setChoices([makeItem("done")]); + + // Exact match with no other prefix matches — uniquely satisfied + const result = menu.updatePrefix("done", pos); + expect(result).toBe(true); + expect(menu.isActive()).toBe(false); + }); +}); diff --git a/ts/packages/shell/test/partialCompletion/separatorMode.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/separatorMode.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/separatorMode.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/separatorMode.spec.ts diff --git a/ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/startIndexSeparatorContract.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/startIndexSeparatorContract.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/startIndexSeparatorContract.spec.ts diff --git a/ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts b/ts/packages/dispatcher/dispatcher/test/partialCompletion/stateTransitions.spec.ts similarity index 100% rename from ts/packages/shell/test/partialCompletion/stateTransitions.spec.ts rename to ts/packages/dispatcher/dispatcher/test/partialCompletion/stateTransitions.spec.ts diff --git a/ts/packages/shell/package.json b/ts/packages/shell/package.json index 9faea04c7b..45d8c0d8bb 100644 --- a/ts/packages/shell/package.json +++ b/ts/packages/shell/package.json @@ -90,7 +90,6 @@ "@playwright/test": "^1.55.0", "@types/debug": "^4.1.12", "@types/jest": "^29.5.7", - "action-grammar": "workspace:*", "concurrently": "^9.1.2", "cross-env": "^7.0.3", "electron": "40.8.5", diff --git a/ts/packages/shell/src/preload/electronTypes.ts b/ts/packages/shell/src/preload/electronTypes.ts index 60db34acd5..d7247050de 100644 --- a/ts/packages/shell/src/preload/electronTypes.ts +++ b/ts/packages/shell/src/preload/electronTypes.ts @@ -32,18 +32,11 @@ export type ClientActions = // end duplicate type section -export type SearchMenuPosition = { - left: number; - bottom: number; -}; - -export type SearchMenuItem = { - matchText: string; - emojiChar?: string | undefined; - sortIndex?: number; - selectedText: string; - needQuotes?: boolean | undefined; // default is true, and will add quote to the selectedText if it has spaces. -}; +import type { + SearchMenuPosition, + SearchMenuItem, +} from "agent-dispatcher/helpers/completion"; +export type { SearchMenuPosition, SearchMenuItem }; export type SearchMenuUIUpdateData = { position?: SearchMenuPosition; diff --git a/ts/packages/shell/src/renderer/src/partial.ts b/ts/packages/shell/src/renderer/src/partial.ts index 8115c277cf..1ad427ea83 100644 --- a/ts/packages/shell/src/renderer/src/partial.ts +++ b/ts/packages/shell/src/renderer/src/partial.ts @@ -9,7 +9,7 @@ import { ICompletionDispatcher, ISearchMenu, PartialCompletionSession, -} from "./partialCompletionSession"; +} from "agent-dispatcher/helpers/completion"; import registerDebug from "debug"; import { ExpandableTextArea } from "./chat/expandableTextArea"; diff --git a/ts/packages/shell/src/renderer/src/search.ts b/ts/packages/shell/src/renderer/src/search.ts index 8f60a277b1..7bab6746d6 100644 --- a/ts/packages/shell/src/renderer/src/search.ts +++ b/ts/packages/shell/src/renderer/src/search.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { isElectron } from "./main"; -import { SearchMenuBase } from "./searchMenuBase"; +import { SearchMenuBase } from "agent-dispatcher/helpers/completion"; import { CompletionToggle } from "./searchMenuUI/completionToggle"; import { InlineSearchMenuUI } from "./searchMenuUI/inlineSearchMenuUI"; import { LocalSearchMenuUI } from "./searchMenuUI/localSearchMenuUI"; diff --git a/ts/packages/shell/src/renderer/src/searchMenuBase.ts b/ts/packages/shell/src/renderer/src/searchMenuBase.ts deleted file mode 100644 index 43e90ee58f..0000000000 --- a/ts/packages/shell/src/renderer/src/searchMenuBase.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { TST } from "./prefixTree.js"; -import { - SearchMenuItem, - SearchMenuPosition, -} from "../../preload/electronTypes.js"; - -export function normalizeMatchText(text: string): string { - // Remove diacritical marks, and replace any space characters with normalized ' '. - return text - .normalize("NFD") - .replace(/[\u0300-\u036f]/g, "") // Remove combining diacritical marks - .replace(/\s/g, " ") - .toLowerCase(); -} - -// Architecture: docs/architecture/completion.md — §7 Shell — Search Menu -export class SearchMenuBase { - private trie: TST = new TST(); - private prefix: string | undefined; - private _active: boolean = false; - - public setChoices(choices: SearchMenuItem[]): void { - this.prefix = undefined; - this.trie.init(); - for (const choice of choices) { - // choices are sorted in priority order so prefer first norm text - const normText = normalizeMatchText(choice.matchText); - if (!this.trie.get(normText)) { - this.trie.insert(normText, choice); - } - } - } - - public numChoices(): number { - return this.trie.size(); - } - - public hasExactMatch(text: string): boolean { - return this.trie.contains(normalizeMatchText(text)); - } - - public updatePrefix(prefix: string, position: SearchMenuPosition): boolean { - if (this.trie.size() === 0) { - return false; - } - - if (this.prefix === prefix && this._active) { - this.onUpdatePosition(position); - return false; - } - - this.prefix = prefix; - const items = this.trie.dataWithPrefix(normalizeMatchText(prefix)); - const uniquelySatisfied = - items.length === 1 && - normalizeMatchText(items[0].matchText) === - normalizeMatchText(prefix); - const showMenu = items.length !== 0 && !uniquelySatisfied; - - if (showMenu) { - this._active = true; - this.onShow(position, prefix, items); - } else { - this.hide(); - } - return uniquelySatisfied; - } - - public hide(): void { - if (this._active) { - this._active = false; - this.onHide(); - } - } - - public isActive(): boolean { - return this._active; - } - - protected onShow( - _position: SearchMenuPosition, - _prefix: string, - _items: SearchMenuItem[], - ): void {} - - protected onUpdatePosition(_position: SearchMenuPosition): void {} - - protected onHide(): void {} -} diff --git a/ts/packages/shell/src/tsconfig.json b/ts/packages/shell/src/tsconfig.json index d4adab848c..c8f4a2d1c3 100644 --- a/ts/packages/shell/src/tsconfig.json +++ b/ts/packages/shell/src/tsconfig.json @@ -6,11 +6,5 @@ "rootDir": ".", "moduleResolution": "node16" }, - "include": [ - "renderer/src/partialCompletionSession.ts", - "renderer/src/prefixTree.ts", - "renderer/src/searchMenuBase.ts", - "preload/electronTypes.ts", - "preload/shellSettingsType.ts" - ] + "include": ["preload/electronTypes.ts", "preload/shellSettingsType.ts"] } diff --git a/ts/packages/shell/test/partialCompletion/helpers.ts b/ts/packages/shell/test/partialCompletion/helpers.ts index 247e1573f1..0eef09bc8c 100644 --- a/ts/packages/shell/test/partialCompletion/helpers.ts +++ b/ts/packages/shell/test/partialCompletion/helpers.ts @@ -1,57 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { jest, type jest as JestTypes } from "@jest/globals"; +// Minimal test helpers for shell-specific completion tests (switchMode). +// The canonical test helpers and most completion tests live in +// packages/dispatcher/dispatcher/test/partialCompletion/. + +import { jest } from "@jest/globals"; import { ICompletionDispatcher, - ISearchMenu, PartialCompletionSession, -} from "../../src/renderer/src/partialCompletionSession.js"; -import { SearchMenuPosition } from "../../src/preload/electronTypes.js"; + SearchMenuPosition, +} from "agent-dispatcher/helpers/completion"; import { CompletionGroup, SeparatorMode } from "@typeagent/agent-sdk"; import { CommandCompletionResult } from "agent-dispatcher"; -import { SearchMenuBase } from "../../src/renderer/src/searchMenuBase.js"; export { PartialCompletionSession }; -export type { ICompletionDispatcher, ISearchMenu }; -export type { CompletionGroup }; -export type { CommandCompletionResult }; export type { SearchMenuPosition }; -type Mocked any> = T & - JestTypes.MockedFunction; - -// Real trie-backed ISearchMenu backed by SearchMenuBase. -// Every method is a jest.fn() wrapping the real implementation so tests can -// assert on call counts and arguments. -export class TestSearchMenu extends SearchMenuBase { - override setChoices: Mocked = jest.fn( - (...args: Parameters) => - super.setChoices(...args), - ) as any; - - override updatePrefix: Mocked = jest.fn( - (prefix: string, position: SearchMenuPosition): boolean => - super.updatePrefix(prefix, position), - ) as any; - - override hasExactMatch: Mocked = jest.fn( - (text: string): boolean => super.hasExactMatch(text), - ) as any; - - override hide: Mocked = jest.fn(() => - super.hide(), - ) as any; - - override isActive: Mocked = jest.fn(() => - super.isActive(), - ) as any; -} - -export function makeMenu(): TestSearchMenu { - return new TestSearchMenu(); -} - export type MockDispatcher = { getCommandCompletion: jest.MockedFunction< ICompletionDispatcher["getCommandCompletion"] @@ -99,34 +64,3 @@ export function makeCompletionResult( ...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, - }; -} - -// Returns the selectedText values from the last setChoices call on a -// TestSearchMenu mock. Avoids repeating the verbose mock.calls pattern. -export function lastSetChoicesItems(menu: TestSearchMenu): string[] { - const calls = menu.setChoices.mock.calls; - return calls[calls.length - 1][0].map( - (i: { selectedText: string }) => i.selectedText, - ); -} diff --git a/ts/packages/shell/test/partialCompletion/switchMode.spec.ts b/ts/packages/shell/test/partialCompletion/switchMode.spec.ts index ff94947aba..5b0fbbe3d3 100644 --- a/ts/packages/shell/test/partialCompletion/switchMode.spec.ts +++ b/ts/packages/shell/test/partialCompletion/switchMode.spec.ts @@ -9,7 +9,7 @@ import { PartialCompletionSession, } from "./helpers.js"; import type { SearchMenuPosition } from "./helpers.js"; -import { SearchMenuBase } from "../../src/renderer/src/searchMenuBase.js"; +import { SearchMenuBase } from "agent-dispatcher/helpers/completion"; import type { SearchMenuItem, SearchMenuUIUpdateData, diff --git a/ts/pnpm-lock.yaml b/ts/pnpm-lock.yaml index a368486bf4..3f9a39119b 100644 --- a/ts/pnpm-lock.yaml +++ b/ts/pnpm-lock.yaml @@ -3558,6 +3558,9 @@ importers: specifier: ^4.1.13 version: 4.1.13 devDependencies: + '@jest/globals': + specifier: ^29.7.0 + version: 29.7.0 '@types/debug': specifier: ^4.1.12 version: 4.1.12 @@ -4363,9 +4366,6 @@ importers: '@types/jest': specifier: ^29.5.7 version: 29.5.14 - action-grammar: - specifier: workspace:* - version: link:../actionGrammar concurrently: specifier: ^9.1.2 version: 9.1.2 diff --git a/ts/tools/scripts/fix-dependabot-alerts.mjs b/ts/tools/scripts/fix-dependabot-alerts.mjs index 482f7aa45d..5903ce70ab 100644 --- a/ts/tools/scripts/fix-dependabot-alerts.mjs +++ b/ts/tools/scripts/fix-dependabot-alerts.mjs @@ -23,13 +23,16 @@ function detectWorkspaceRoot() { try { const gitRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", - }).trim().replace(/\\/g, "/"); + }) + .trim() + .replace(/\\/g, "/"); const cwdNorm = process.cwd().replace(/\\/g, "/"); - const rel = cwdNorm === gitRoot - ? "" - : cwdNorm.startsWith(gitRoot + "/") - ? cwdNorm.slice(gitRoot.length + 1) - : ""; + const rel = + cwdNorm === gitRoot + ? "" + : cwdNorm.startsWith(gitRoot + "/") + ? cwdNorm.slice(gitRoot.length + 1) + : ""; const wsPrefix = rel ? rel.split("/")[0] : ""; return wsPrefix ? resolve(gitRoot, wsPrefix) : resolve(cwdNorm); } catch { @@ -2105,13 +2108,18 @@ function fetchAlerts() { // even when the script is invoked from a subdirectory like ts/tools). let wsPrefix = ""; try { - const gitRoot = runCmd("git", ["rev-parse", "--show-toplevel"]).replace(/\\/g, "/"); + const gitRoot = runCmd("git", ["rev-parse", "--show-toplevel"]).replace( + /\\/g, + "/", + ); const rootNorm = ROOT.replace(/\\/g, "/"); wsPrefix = rootNorm.startsWith(gitRoot + "/") ? rootNorm.slice(gitRoot.length + 1).split("/")[0] : ""; } catch { - verbose(" Could not determine git root; skipping workspace-specific alert filtering."); + verbose( + " Could not determine git root; skipping workspace-specific alert filtering.", + ); } if (wsPrefix) { const before = alerts.length; @@ -2120,7 +2128,9 @@ function fetchAlerts() { return manifest.startsWith(wsPrefix + "/") || manifest === wsPrefix; }); if (alerts.length < before) { - verbose(` Filtered to ${alerts.length}/${before} alerts matching workspace "${wsPrefix}/"`); + verbose( + ` Filtered to ${alerts.length}/${before} alerts matching workspace "${wsPrefix}/"`, + ); } }