LCOV - code coverage report
Current view: top level - dashql-app/src/agent - use_composer_input_mode.ts Coverage Total Hit
Test: DashQL 6e74948 Lines: 100.0 % 65 65
Test Date: 2026-11-06 17:46:50

            Line data    Source code
       1              : import * as React from 'react';
       2              : import * as core from '@ankoh/dashql-core';
       3              : 
       4              : import type { Extension } from '@codemirror/state';
       5              : 
       6              : import { buildCatalogNameIndex, CatalogNameIndex, EMPTY_CATALOG_NAME_INDEX } from './catalog_name_index.js';
       7              : import { naturalLanguageAssist } from './nl_identifier_plugin.js';
       8              : import { assessSql, ComposerInputMode, decideMode } from './input_mode.js';
       9              : import { useLogger } from '../platform/logger/logger_provider.js';
      10              : 
      11            1 : const LOG_CTX = 'composer_input_mode';
      12              : 
      13              : /// The input mode of a composer draft
      14              : export interface ComposerInputModeState {
      15              :     /// The mode of the draft, always SQL while the agent is unavailable
      16              :     mode: ComposerInputMode;
      17              :     /// The editor assistance of the mode, undefined for the default SQL assistance
      18              :     assist: Extension | undefined;
      19              :     /// Called with the draft text whenever it changes, a cleared draft starts over in natural language
      20              :     onTextChange: (text: string) => void;
      21              :     /// Called with every editor update of the draft session, re-evaluates the mode
      22              :     onEditorUpdate: (update: core.buffers.editor.EditorUpdateT) => void;
      23              :     /// The user picked a mode, it sticks until the draft is cleared
      24              :     pick: (mode: ComposerInputMode) => void;
      25              :     /// Decide the mode of the draft that is being sent
      26              :     resolveSendMode: () => ComposerInputMode;
      27              : }
      28              : 
      29              : /// Tell SQL from natural language in a composer draft.
      30              : ///
      31              : /// The draft starts in natural language and switches to SQL as soon as it is convincingly SQL,
      32              : /// until the user picks a mode explicitly. Natural language highlights and completes the names of the catalog,
      33              : /// and highlights the references to the session scripts.
      34            1 : export function useComposerInputMode(catalog: core.DashQLCatalog, aiAvailable: boolean, scriptNames: readonly string[]): ComposerInputModeState {
      35            1 :     const logger = useLogger();
      36              : 
      37              :     // The input mode follows the draft until the user picks one.
      38              :     // The ref holds the latest mode for the editor callbacks that decide the next one.
      39            1 :     const [mode, setModeState] = React.useState(ComposerInputMode.Ai);
      40            1 :     const modeRef = React.useRef(mode);
      41            1 :     const modeOverridden = React.useRef(false);
      42            1 :     const setMode = React.useCallback((next: ComposerInputMode, reason: string, details: Record<string, string> = {}) => {
      43            1 :         if (modeRef.current === next) return;
      44            1 :         logger.debug('Switched the composer input mode', { mode: ComposerInputMode[next], reason, ...details }, LOG_CTX);
      45            1 :         modeRef.current = next;
      46            1 :         setModeState(next);
      47            1 :     }, [logger]);
      48            1 :     const onTextChange = React.useCallback((text: string) => {
      49            1 :         if (text.trim().length === 0) {
      50            1 :             setMode(ComposerInputMode.Ai, 'cleared');
      51            1 :             modeOverridden.current = false;
      52            1 :         }
      53            1 :     }, [setMode]);
      54            1 :     const pick = React.useCallback((next: ComposerInputMode) => {
      55            1 :         setMode(next, 'picked');
      56            1 :         modeOverridden.current = true;
      57            1 :     }, [setMode]);
      58              : 
      59              :     // The catalog names that are highlighted and completed in natural language.
      60              :     // Rebuilt whenever the revision of the catalog moves, null forces a rebuild.
      61            1 :     const [identifierIndex, setIdentifierIndex] = React.useState<CatalogNameIndex>(EMPTY_CATALOG_NAME_INDEX);
      62            1 :     const indexedRevision = React.useRef<bigint | null>(null);
      63            1 :     const refreshIdentifierIndex = React.useCallback((revision: bigint | null) => {
      64            1 :         if (revision != null && revision === indexedRevision.current) return;
      65            1 :         indexedRevision.current = revision;
      66            1 :         const index = buildCatalogNameIndex(catalog);
      67            1 :         logger.debug('Built the catalog name index of the composer', {
      68            1 :             names: String(index.keys.length),
      69            1 :             catalogRevision: revision?.toString() ?? null,
      70            1 :         }, LOG_CTX);
      71            1 :         setIdentifierIndex(index);
      72            1 :     }, [catalog, logger]);
      73            1 :     React.useEffect(() => {
      74            1 :         if (aiAvailable) refreshIdentifierIndex(null);
      75            1 :     }, [aiAvailable, refreshIdentifierIndex]);
      76              : 
      77              :     // Every edit of the draft re-evaluates the mode.
      78              :     // The token at the cursor is still being typed and does not count yet.
      79            1 :     const lastUpdate = React.useRef<core.buffers.editor.EditorUpdateT | null>(null);
      80            1 :     const latest = React.useRef({ aiAvailable, refreshIdentifierIndex });
      81            1 :     latest.current = { aiAvailable, refreshIdentifierIndex };
      82            1 :     const onEditorUpdate = React.useCallback((update: core.buffers.editor.EditorUpdateT) => {
      83            1 :         lastUpdate.current = update;
      84            1 :         if (!latest.current.aiAvailable) return;
      85            1 :         latest.current.refreshIdentifierIndex(update.catalogRevision);
      86            1 :         if (!update.textChanged || modeOverridden.current) return;
      87            1 :         const cursor = update.primarySelection != null ? Number(update.primarySelection.head) : null;
      88            1 :         const evidence = assessSql(update, cursor);
      89            1 :         setMode(decideMode(modeRef.current, evidence), 'detected', {
      90            1 :             validTokens: String(evidence.validTokens),
      91            1 :             tailTokens: String(evidence.tailTokens),
      92            1 :         });
      93            1 :     }, [setMode]);
      94              : 
      95              :     // The draft is final now, every token counts
      96            1 :     const resolveSendMode = React.useCallback(() => {
      97            1 :         if (!latest.current.aiAvailable) return ComposerInputMode.Sql;
      98            1 :         if (modeOverridden.current || lastUpdate.current == null) return modeRef.current;
      99            1 :         const next = decideMode(modeRef.current, assessSql(lastUpdate.current, null), true);
     100            1 :         setMode(next, 'sent');
     101            1 :         return next;
     102            1 :     }, [setMode]);
     103              : 
     104            1 :     const effectiveMode = aiAvailable ? mode : ComposerInputMode.Sql;
     105            1 :     const assist = React.useMemo(
     106            1 :         () => effectiveMode === ComposerInputMode.Ai ? naturalLanguageAssist(identifierIndex, scriptNames) : undefined,
     107            1 :         [effectiveMode, identifierIndex, scriptNames],
     108              :     );
     109            1 :     return { mode: effectiveMode, assist, onTextChange, onEditorUpdate, pick, resolveSendMode };
     110            1 : }
        

Generated by: LCOV version 2.0-1