Skip to content

refactor: modularize completion system — extract from shell to dispatcher with CompletionController interface#2189

Merged
curtisman merged 25 commits intomainfrom
copilot/refactor-cli-completion-code
Apr 18, 2026
Merged

refactor: modularize completion system — extract from shell to dispatcher with CompletionController interface#2189
curtisman merged 25 commits intomainfrom
copilot/refactor-cli-completion-code

Conversation

@curtisman
Copy link
Copy Markdown
Member

@curtisman curtisman commented Apr 12, 2026

Summary

Extracts the completion infrastructure from the shell (Electron) package into a modular dispatcher/helpers/completion/ directory, introducing a clean CompletionController interface that both the shell and CLI consume as host adapters. The completion session state machine, trie index, and search menu logic are now fully DOM-free and independently unit-testable.

Motivation

Previously, PartialCompletionSession, the prefix trie (TST), and SearchMenuBase all lived inside the shell renderer — tightly coupled to Electron/DOM and impossible to test without a browser environment. The CLI duplicated completion filtering logic instead of reusing the session. This refactor:

  • Makes completion logic reusable across shell and CLI via a single CompletionController interface
  • Enables comprehensive unit testing with Jest (no DOM needed)
  • Eliminates ~120 lines of duplicated completion logic in the CLI
  • Establishes clean separation of concerns: state machine (dispatcher) vs rendering (shell/CLI)

Architecture

User keystroke → CompletionController.update()
                     ↓
              PartialCompletionSession (state machine: IDLE → PENDING → ACTIVE)
                     ↓
              ICompletionDispatcher.getCommandCompletion()
                     ↓
              Grammar result → SearchMenuIndex (TST trie) → CompletionState
                     ↓
              onUpdate callback → Host renders (shell SearchMenu / CLI ghost-text)

New module structure (dispatcher/src/helpers/completion/)

File Responsibility
index.ts Barrel re-exports for external consumers
controller.ts CompletionController interface (7 methods) + createCompletionController factory
session.ts PartialCompletionSession — core IDLE→PENDING→ACTIVE state machine
searchMenu.ts SearchMenuItem, SearchMenuIndex interface, TSTSearchMenuIndex (TST-backed prefix index)
trie.ts Ternary Search Tree (TST) data structure, normalizeMatchText()

Key abstractions

  • CompletionController — consumer-facing interface: update(), accept(), dismiss(), hide(), getCompletionState(), setOnUpdate(), dispose()
  • SearchMenuIndexfilterItems(), numItems(), setItems(), hasExactMatch() backed by TST
  • CompletionState{ items, prefix, anchorIndex, generation } where generation is a monotonically-increasing counter for item-change detection
  • NoMatchPolicy — internal tri-state (accept | refetch | slide) replacing scattered boolean checks
  • ICompletionDispatcher — minimal interface the session needs from the dispatcher

Changes

Dispatcher (packages/dispatcher)

  • Deleted src/helpers/completion.ts (old thin re-export shim)
  • Added src/helpers/completion/ directory with 5 modules (see table above)
  • PartialCompletionSession implements CompletionController directly (no wrapper class)
  • Session uses generation counter so hosts can distinguish "items changed" from "prefix-only update"
  • Type-ahead reconciliation: lastInput/lastDirection fields detect stale async results and re-fetch or re-filter as needed
  • Package export path agent-dispatcher/helpers/completion preserved (target changed from dist/helpers/completion.jsdist/helpers/completion/index.js)

Shell (packages/shell)

  • Deleted src/renderer/src/searchMenuBase.ts — replaced by dispatcher's SearchMenuIndex
  • SearchMenu no longer extends a base class; now a flat class with explicit render(prefix, items) and updatePosition(prefix) methods
  • Position computation injected via constructor (getPosition callback) instead of per-call arguments
  • PartialCompletion creates a CompletionController via factory and uses setOnUpdate() for async render wiring
  • Tracks lastGeneration and lastPrefix to decide render() vs updatePosition()
  • templateEditor.ts imports from agent-dispatcher/helpers/completion instead of shell re-export layer
  • Removed action-grammar dev dependency from shell

CLI (packages/cli)

  • connect.ts imports createCompletionController from dispatcher and passes controller to enhanced console
  • enhancedConsole.ts receives a CompletionController instead of managing its own completion state
  • Replaced manual filtering logic (allCompletions, completionPrefix, inline filtering) with controller.getCompletionState() in render loop
  • onUpdate callback triggers re-render on async completion arrival

Tests

Moved from shell → dispatcher (13 test files at dispatcher/test/partialCompletion/):

File Coverage
controller.spec.ts New — factory lifecycle, update/accept/dismiss/hide, setOnUpdate callback replacement
generation.spec.ts NewCompletionState.generation counter semantics
searchMenuIndex.spec.ts New — TST filtering, case insensitivity, diacritical normalization, setItems replacement
typeAhead.spec.ts New — type-ahead reconciliation: fast typing, diverged input, direction changes during pending fetch
helpers.ts New — shared utilities: makeSession, makeDispatcher, makeCompletionResult, flushPromises, loadedItems
direction.spec.ts Updated to new API surface
errorHandling.spec.ts Updated to new API surface
grammarE2E.spec.ts Updated to new API surface
publicAPI.spec.ts Updated to new API surface
resultProcessing.spec.ts Updated to new API surface
separatorMode.spec.ts Updated to new API surface
startIndexSeparatorContract.spec.ts Updated to new API surface
stateTransitions.spec.ts Updated to new API surface

Retained in shell (DOM-specific):

  • renderDispatch.spec.tsNew — tests render() vs updatePosition() dispatch based on generation counter
  • switchMode.spec.tsSearchMenu.switchMode() inline↔expanded toggle

Docs

  • Updated docs/architecture/completion.md with new module structure, CompletionController interface, and state machine description

Cleanup

  • Removed needsSeparatorInAutoMode re-export wrapper (session imports directly)
  • Removed unused shell re-export wrappers
  • Jest config updated with moduleNameMapper for ../src/dist/ resolution

Copilot AI and others added 6 commits April 11, 2026 06:17
…l + CLI to use them

Agent-Logs-Url: https://github.com/microsoft/TypeAgent/sessions/8766b4b5-3498-4828-8c69-9f701f721f2a

Co-authored-by: curtisman <12101885+curtisman@users.noreply.github.com>
…prove comment clarity

Agent-Logs-Url: https://github.com/microsoft/TypeAgent/sessions/8766b4b5-3498-4828-8c69-9f701f721f2a

Co-authored-by: curtisman <12101885+curtisman@users.noreply.github.com>
… wrappers

- Rename debug namespace from typeagent:shell:partial to typeagent:completion:partial
- Narrow barrel export in completion/index.ts (exclude TSTNode)
- Clarify needQuotes comment in SearchMenuItem
- Delete shell re-export files (partialCompletionSession.ts, prefixTree.ts, searchMenuBase.ts)
- Update shell imports to use agent-dispatcher/helpers/completion directly
- Move 8 PartialCompletionSession test suites to dispatcher package
- Fix pre-existing TS2304 in electronTypes.ts (split export type into import + re-export)
- Add moduleNameMapper for subdirectory tests in dispatcher jest config
- Use undefined instead of null for session/menu state in enhancedConsole
- Extract repeated position callback into shared getPos const
- Remove redundant render() calls after session.update() (menu callback handles it)
- Add session.resetToIdle() on Enter path for consistency with Tab
- Add CompletionSource<T> type alias for union parameter
- Trim shell test helpers to minimal subset needed by switchMode tests
- Make jest moduleNameMapper depth-agnostic for src/ imports
- Add SearchMenuBase adapter unit tests
Replace the dual getCompletions/completionDispatcher paths with a single
CompletionController that wraps PartialCompletionSession + ISearchMenu.

- Add CompletionController, CallbackSearchMenu, and createCompletionController
  factory in dispatcher/helpers/completion/controller.ts
- Add getFilteredItems() to SearchMenuBase and ISearchMenu
- Add CompletionState type and getCompletionState() to PartialCompletionSession
- Refactor CLI: simplify questionWithCompletion to use CompletionController,
  remove ~150 lines of dead callback-mode code, delete cliSearchMenu.ts
- Refactor Shell: partial.ts uses CompletionController instead of
  PartialCompletionSession directly
- Narrow public API: remove BaseTSTData, TST, normalizeMatchText,
  PartialCompletionSession, and ICompletionDispatcher from public exports
- Update switchMode integration tests to use CompletionController
- Add 11 new controller tests (CLI path, Shell path, prefix extraction)
The backspace and printable character handlers updated the input buffer
and called controller.update() but never called render(), so typed text
was invisible. Every other key handler (arrows, Tab, Escape) already
called render() correctly.
Add setOnUpdate() to CompletionController so the CLI can hook render()
into the internal CallbackSearchMenu. Without this, the first keystroke
triggered the async fetch but render() only ran synchronously before
results arrived — completions only appeared on the next keystroke.
- Extract TST into trie.ts, introduce SearchMenuDataProvider interface
- Replace SearchMenuBase with HeadlessSearchMenu (dispatcher) and
  slimmed SearchMenu (shell)
- Make CompletionController implement SearchMenuDataProvider so it
  serves as the data source for SearchMenu
- Minimize ISearchMenuControl to three command methods:
  updatePrefix, hide, invalidate — remove query methods
  (getFilteredItems, isActive) that round-tripped data PCS already owns
- Move SearchMenuPosition out of dispatcher: position resolution is
  now purely a shell concern via getPosition callback in SearchMenu
- PCS queries its own trie directly in getCompletionState and
  matchOrConsume instead of reading back through the menu
- Store lastInput in PartialCompletionSession on update()/explicitHide()
- Make getCompletionState() parameterless (no longer takes input)
- Compute and cache CompletionState at every mutation point (notifyUpdate)
- getCompletionState() is now a simple getter returning cached state
- Remove getCompletionPrefix() — callers use getCompletionState()?.prefix
- Remove hidden flag — hide paths set completionState = undefined directly
- Update controller, shell partial.ts, CLI enhancedConsole.ts callers
- Update all test files to match new no-arg signatures
When the user types faster than the completion backend responds, the
.then()/.catch() callbacks in startNewSession used stale captured
input/direction values, causing missed re-fetches or inconsistent
onUpdate callbacks.

Changes:
- Add lastDirection field to track the most recent direction
- Add reconcileTypeAhead() helper that uses lastInput/lastDirection
  to evaluate the current input against the session (via reuseSession)
  after a fetch completes, issuing a new fetch only when needed
- Apply reconcileTypeAhead in .then() (empty result, normal result)
  and .catch() (error recovery) paths
- Fix explicitCloseAnchor suppression to account for type-ahead
- Add typeAhead.spec.ts with 19 tests covering type-ahead behavior
  and verifying exact fetch counts (no extraneous re-fetches)
- Remove SearchMenuDataProvider interface; inline methods into
  MutableSearchMenuDataProvider
- Remove unnecessary exports from completion index (SearchMenuDataProvider,
  TSTSearchMenuDataProvider, SearchMenuPosition)
- Move SearchMenuPosition type to shell (electronTypes.ts)
- Remove filterItems/numChoices from PartialCompletionSession public API
- Replace test introspection (trieItems) with public getCompletionState()
  and loadedItems helper
- Remove unused dataProvider parameter from TestableSearchMenu constructor
- Refactor createSearchMenu in templateEditor.ts to return {searchMenu,
  dataProvider} object; store as single searchMenuData field on editUI
- Cast trieProvider to typed TSTSearchMenuDataProvider in test helpers
…rchMenuDataProvider to createSearchMenuIndex
…fix test helpers

- Replace CompletionController class with an interface restricting the
  public API to consumer-facing methods (update, accept, dismiss, hide,
  getCompletionState, setOnUpdate). Internal methods like resetToIdle,
  explicitHide, and getLoadedItems are no longer exposed to consumers.
- Add accept() and dismiss() aliases on PartialCompletionSession.
- Add getLoadedItems() test-only method, removing unsafe 'as unknown as'
  cast in test helpers.
- Add shared flushPromises() to dispatcher test helpers; remove duplicate
  from controller.spec.ts.
- Document retained SearchMenu state fields (switchMode re-creation).
- Add error→type-ahead→re-fetch round-trip test in typeAhead.spec.ts.
switchMode() no longer re-creates the UI internally. Instead it closes
the old UI, flips the inline flag, and resets prefix. The caller
(partial.ts) re-renders from the controller's current completion state.

This removes lastPosition/lastPrefix/lastItems fields from SearchMenu,
making it a stateless renderer. updateToggleLayout() now takes position
as a parameter.
- Rename TSTSearchMenuDataProvider → TSTSearchMenuIndex
- Rename setChoices/numChoices → setItems/numItems for consistency
- Rename trieProvider field → searchMenuIndex
- Inline resetToIdle into accept(), explicitHide into dismiss()
- Rename searchMenuBase.spec.ts → searchMenuIndex.spec.ts
- Update tests: explicitHide → dismiss, resetToIdle → accept
- Update completion.md: fix section numbers (5a/5b/5c → 5/6/7),
  correct package location, rewrite stale §5c (SearchMenuBase removed),
  update CLI integration for backward direction support,
  clarify anchor terminology in Data flow section
SearchMenu.render() had a position-only shortcut that skipped item
updates when the prefix didn't change, even though sep-level transitions
loaded different items into the trie.

- Add generation counter to CompletionState, incremented on every
  loadLevel() call in PartialCompletionSession
- Split SearchMenu into render() (full update) and updatePosition()
  (position-only); remove the stale prefix/anchorIndex shortcut
- Move render-vs-position decision to PartialCompletion (the caller),
  comparing both generation and prefix to detect item changes
- Update TestableSearchMenu in switchMode.spec.ts to mirror changes
- Add generation.spec.ts for generation counter behavior
- Add renderDispatch.spec.ts for render vs updatePosition dispatch
- Remove computeCompletionState() and notifyUpdate() — the hot path
  (C3 ACTIVE) now inlines CompletionState construction since items
  are already computed by filterItems().
- Add filterForState() for cold paths (slideAnchor, dismiss level-shift)
  that still need trie filtering.
- Add setCompletionState() to unify all state+callback sites, replacing
  the old notifyHide() + inline completionState/onUpdate pattern.
- Fix slideAnchor: guard against returning items when menuSepLevel > 0
  (deferred state requires separator before showing items).
…, enforce interface

- Guard setCompletionState to skip undefined→undefined no-ops, eliminating
  redundant onUpdate/hide() calls when the menu is already hidden
- Cache committedSepRe regex at module scope (hot path in matchOrConsume)
- Route accept() through setCompletionState for consistent callback firing
- Add implements CompletionController to PartialCompletionSession
- Add debug log in reconcileTypeAhead when suppressing re-fetch
- Improve getLoadedItems JSDoc with @internal tag
- Update tests to assert the no-op optimization
…ranch

# Conflicts:
#	ts/docs/architecture/completion.md
#	ts/packages/cli/src/commands/connect.ts
#	ts/packages/cli/src/commands/interactive.ts
#	ts/packages/cli/src/enhancedConsole.ts
#	ts/pnpm-lock.yaml
…s, robust flush

- Convert startNewSession from .then()/.catch() to async/await with a
  single try/catch covering both fetch rejections and processing errors
- Expand searchMenuIndex.spec.ts with tests for empty prefix, case
  insensitivity, diacritical normalization, duplicate handling, setItems
  replacement, and isUniquelySatisfied edge cases
- Replace fragile hardcoded Promise.resolve() microtask flushing with
  setTimeout-based flushPromises that drains the full microtask queue
- Update errorHandling test expectations for async/await microtask timing
- Add fetchNewSession() sync wrapper with terminal .catch() so
  unhandled rejections never escape from fire-and-forget async calls.
  reconcileTypeAhead now routes through fetchNewSession as well.
- Add dispose() to CompletionController interface and
  PartialCompletionSession: clears state and detaches onUpdate callback.
- Shell PartialCompletion.close() calls dispose() instead of hide()
  to prevent leaked closures holding DOM references.
- Rename stale test names referencing removed getCompletionPrefix API
  to completionState.prefix.
…factor

- Replace 6 stale computeCompletionState references with filterForState
- Update 3 partialCompletionSession.ts references in actionGrammar.md to session.ts
- Change shell-specific language to host-agnostic in session.ts comments
- Rename explicitCloseAnchor to dismissAnchor to align with public API
- Replace stale setChoices references with loadLevel in test comments
- Fix misleading accept() test: split into two cases (visible vs hidden)
- Change 'Hide the menu' to 'Hide completions' in host-agnostic code
@curtisman curtisman changed the title refactor: move completion session/menu infrastructure from shell to dispatcher refactor: modularize completion system — extract from shell to dispatcher with CompletionController interface Apr 17, 2026
@curtisman curtisman added this pull request to the merge queue Apr 18, 2026
Merged via the queue into main with commit 86cdbf0 Apr 18, 2026
19 of 21 checks passed
@curtisman curtisman deleted the copilot/refactor-cli-completion-code branch April 20, 2026 20:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants