/ utils / handlePromptSubmit.ts
handlePromptSubmit.ts
  1  import type { UUID } from 'crypto'
  2  import { logEvent } from 'src/services/analytics/index.js'
  3  import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
  4  import { type Command, getCommandName, isCommandEnabled } from '../commands.js'
  5  import { selectableUserMessagesFilter } from '../components/MessageSelector.js'
  6  import type { SpinnerMode } from '../components/Spinner/types.js'
  7  import type { QuerySource } from '../constants/querySource.js'
  8  import { expandPastedTextRefs, parseReferences } from '../history.js'
  9  import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
 10  import type { IDESelection } from '../hooks/useIdeSelection.js'
 11  import type { AppState } from '../state/AppState.js'
 12  import type { SetToolJSXFn } from '../Tool.js'
 13  import type { LocalJSXCommandOnDone } from '../types/command.js'
 14  import type { Message } from '../types/message.js'
 15  import {
 16    isValidImagePaste,
 17    type PromptInputMode,
 18    type QueuedCommand,
 19  } from '../types/textInputTypes.js'
 20  import { createAbortController } from './abortController.js'
 21  import type { PastedContent } from './config.js'
 22  import { logForDebugging } from './debug.js'
 23  import type { EffortValue } from './effort.js'
 24  import type { FileHistoryState } from './fileHistory.js'
 25  import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
 26  import { gracefulShutdownSync } from './gracefulShutdown.js'
 27  import { enqueue } from './messageQueueManager.js'
 28  import { resolveSkillModelOverride } from './model/model.js'
 29  import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
 30  import { processUserInput } from './processUserInput/processUserInput.js'
 31  import type { QueryGuard } from './QueryGuard.js'
 32  import { queryCheckpoint, startQueryProfile } from './queryProfiler.js'
 33  import { runWithWorkload } from './workloadContext.js'
 34  
 35  function exit(): void {
 36    gracefulShutdownSync(0)
 37  }
 38  
 39  type BaseExecutionParams = {
 40    queuedCommands?: QueuedCommand[]
 41    messages: Message[]
 42    mainLoopModel: string
 43    ideSelection: IDESelection | undefined
 44    querySource: QuerySource
 45    commands: Command[]
 46    queryGuard: QueryGuard
 47    /**
 48     * True when external loading (remote session, foregrounded background task)
 49     * is active. These don't route through queryGuard, so the queue check must
 50     * account for them separately. Omit (defaults to false) for the dequeue path
 51     * (executeQueuedInput) — dequeued items were already queued past this check.
 52     */
 53    isExternalLoading?: boolean
 54    setToolJSX: SetToolJSXFn
 55    getToolUseContext: (
 56      messages: Message[],
 57      newMessages: Message[],
 58      abortController: AbortController,
 59      mainLoopModel: string,
 60    ) => ProcessUserInputContext
 61    setUserInputOnProcessing: (prompt?: string) => void
 62    setAbortController: (abortController: AbortController | null) => void
 63    onQuery: (
 64      newMessages: Message[],
 65      abortController: AbortController,
 66      shouldQuery: boolean,
 67      additionalAllowedTools: string[],
 68      mainLoopModel: string,
 69      onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>,
 70      input?: string,
 71      effort?: EffortValue,
 72    ) => Promise<void>
 73    setAppState: (updater: (prev: AppState) => AppState) => void
 74    onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>
 75    canUseTool?: CanUseToolFn
 76  }
 77  
 78  /**
 79   * Parameters for core execution logic (no UI concerns).
 80   */
 81  type ExecuteUserInputParams = BaseExecutionParams & {
 82    resetHistory: () => void
 83    onInputChange: (value: string) => void
 84  }
 85  
 86  export type PromptInputHelpers = {
 87    setCursorOffset: (offset: number) => void
 88    clearBuffer: () => void
 89    resetHistory: () => void
 90  }
 91  
 92  export type HandlePromptSubmitParams = BaseExecutionParams & {
 93    // Direct user input path (set when called from onSubmit, absent for queue processor)
 94    input?: string
 95    mode?: PromptInputMode
 96    pastedContents?: Record<number, PastedContent>
 97    helpers: PromptInputHelpers
 98    onInputChange: (value: string) => void
 99    setPastedContents: React.Dispatch<
100      React.SetStateAction<Record<number, PastedContent>>
101    >
102    abortController?: AbortController | null
103    addNotification?: (notification: {
104      key: string
105      text: string
106      priority: 'low' | 'medium' | 'high' | 'immediate'
107    }) => void
108    setMessages?: (updater: (prev: Message[]) => Message[]) => void
109    streamMode?: SpinnerMode
110    hasInterruptibleToolInProgress?: boolean
111    uuid?: UUID
112    /**
113     * When true, input starting with `/` is treated as plain text.
114     * Used for remotely-received messages (bridge/CCR) that should not
115     * trigger local slash commands or skills.
116     */
117    skipSlashCommands?: boolean
118  }
119  
120  export async function handlePromptSubmit(
121    params: HandlePromptSubmitParams,
122  ): Promise<void> {
123    const {
124      helpers,
125      queryGuard,
126      isExternalLoading = false,
127      commands,
128      onInputChange,
129      setPastedContents,
130      setToolJSX,
131      getToolUseContext,
132      messages,
133      mainLoopModel,
134      ideSelection,
135      setUserInputOnProcessing,
136      setAbortController,
137      onQuery,
138      setAppState,
139      onBeforeQuery,
140      canUseTool,
141      queuedCommands,
142      uuid,
143      skipSlashCommands,
144    } = params
145  
146    const { setCursorOffset, clearBuffer, resetHistory } = helpers
147  
148    // Queue processor path: commands are pre-validated and ready to execute.
149    // Skip all input validation, reference parsing, and queuing logic.
150    if (queuedCommands?.length) {
151      startQueryProfile()
152      await executeUserInput({
153        queuedCommands,
154        messages,
155        mainLoopModel,
156        ideSelection,
157        querySource: params.querySource,
158        commands,
159        queryGuard,
160        setToolJSX,
161        getToolUseContext,
162        setUserInputOnProcessing,
163        setAbortController,
164        onQuery,
165        setAppState,
166        onBeforeQuery,
167        resetHistory,
168        canUseTool,
169        onInputChange,
170      })
171      return
172    }
173  
174    const input = params.input ?? ''
175    const mode = params.mode ?? 'prompt'
176    const rawPastedContents = params.pastedContents ?? {}
177  
178    // Images are only sent if their [Image #N] placeholder is still in the text.
179    // Deleting the inline pill drops the image; orphaned entries are filtered here.
180    const referencedIds = new Set(parseReferences(input).map(r => r.id))
181    const pastedContents = Object.fromEntries(
182      Object.entries(rawPastedContents).filter(
183        ([, c]) => c.type !== 'image' || referencedIds.has(c.id),
184      ),
185    )
186  
187    const hasImages = Object.values(pastedContents).some(isValidImagePaste)
188    if (input.trim() === '') {
189      return
190    }
191  
192    // Handle exit commands by triggering the exit command instead of direct process.exit
193    // Skip for remote bridge messages — "exit" typed on iOS shouldn't kill the local session
194    if (
195      !skipSlashCommands &&
196      ['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim())
197    ) {
198      // Trigger the exit command which will show the feedback dialog
199      const exitCommand = commands.find(cmd => cmd.name === 'exit')
200      if (exitCommand) {
201        // Submit the /exit command instead - recursive call needs to be handled
202        void handlePromptSubmit({
203          ...params,
204          input: '/exit',
205        })
206      } else {
207        // Fallback to direct exit if exit command not found
208        exit()
209      }
210      return
211    }
212  
213    // Parse references and replace with actual content early, before queueing
214    // or immediate-command dispatch, so queued commands and immediate commands
215    // both receive the expanded text from when it was submitted.
216    const finalInput = expandPastedTextRefs(input, pastedContents)
217    const pastedTextRefs = parseReferences(input).filter(
218      r => pastedContents[r.id]?.type === 'text',
219    )
220    const pastedTextCount = pastedTextRefs.length
221    const pastedTextBytes = pastedTextRefs.reduce(
222      (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0),
223      0,
224    )
225    logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes })
226  
227    // Handle local-jsx immediate commands (e.g., /config, /doctor)
228    // Skip for remote bridge messages — slash commands from CCR clients are plain text
229    if (!skipSlashCommands && finalInput.trim().startsWith('/')) {
230      const trimmedInput = finalInput.trim()
231      const spaceIndex = trimmedInput.indexOf(' ')
232      const commandName =
233        spaceIndex === -1
234          ? trimmedInput.slice(1)
235          : trimmedInput.slice(1, spaceIndex)
236      const commandArgs =
237        spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim()
238  
239      const immediateCommand = commands.find(
240        cmd =>
241          cmd.immediate &&
242          isCommandEnabled(cmd) &&
243          (cmd.name === commandName ||
244            cmd.aliases?.includes(commandName) ||
245            getCommandName(cmd) === commandName),
246      )
247  
248      if (
249        immediateCommand &&
250        immediateCommand.type === 'local-jsx' &&
251        (queryGuard.isActive || isExternalLoading)
252      ) {
253        logEvent('tengu_immediate_command_executed', {
254          commandName:
255            immediateCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
256        })
257  
258        // Clear input
259        onInputChange('')
260        setCursorOffset(0)
261        setPastedContents({})
262        clearBuffer()
263  
264        const context = getToolUseContext(
265          messages,
266          [],
267          createAbortController(),
268          mainLoopModel,
269        )
270  
271        let doneWasCalled = false
272        const onDone: LocalJSXCommandOnDone = (result, options) => {
273          doneWasCalled = true
274          // Use clearLocalJSX to explicitly clear the local JSX command
275          setToolJSX({
276            jsx: null,
277            shouldHidePromptInput: false,
278            clearLocalJSX: true,
279          })
280          if (result && options?.display !== 'skip' && params.addNotification) {
281            params.addNotification({
282              key: `immediate-${immediateCommand.name}`,
283              text: result,
284              priority: 'immediate',
285            })
286          }
287          if (options?.nextInput) {
288            if (options.submitNextInput) {
289              enqueue({ value: options.nextInput, mode: 'prompt' })
290            } else {
291              onInputChange(options.nextInput)
292            }
293          }
294        }
295  
296        const impl = await immediateCommand.load()
297        const jsx = await impl.call(onDone, context, commandArgs)
298  
299        // Skip if onDone already fired — prevents stuck isLocalJSXCommand
300        // (see processSlashCommand.tsx local-jsx case for full mechanism).
301        if (jsx && !doneWasCalled) {
302          setToolJSX({
303            jsx,
304            shouldHidePromptInput: false,
305            isLocalJSXCommand: true,
306            isImmediate: true,
307          })
308        }
309        return
310      }
311    }
312  
313    if (queryGuard.isActive || isExternalLoading) {
314      // Only allow prompt and bash mode commands to be queued
315      if (mode !== 'prompt' && mode !== 'bash') {
316        return
317      }
318  
319      // Interrupt the current turn when all executing tools have
320      // interruptBehavior 'cancel' (e.g. SleepTool).
321      if (params.hasInterruptibleToolInProgress) {
322        logForDebugging(
323          `[interrupt] Aborting current turn: streamMode=${params.streamMode}`,
324        )
325        logEvent('tengu_cancel', {
326          source:
327            'interrupt_on_submit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
328          streamMode:
329            params.streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
330        })
331        params.abortController?.abort('interrupt')
332      }
333  
334      // Enqueue with string value + raw pastedContents. Images will be resized
335      // at execution time when processUserInput runs (not baked in here).
336      enqueue({
337        value: finalInput.trim(),
338        preExpansionValue: input.trim(),
339        mode,
340        pastedContents: hasImages ? pastedContents : undefined,
341        skipSlashCommands,
342        uuid,
343      })
344  
345      onInputChange('')
346      setCursorOffset(0)
347      setPastedContents({})
348      resetHistory()
349      clearBuffer()
350      return
351    }
352  
353    // Start query profiling for this query
354    startQueryProfile()
355  
356    // Construct a QueuedCommand from the direct user input so both paths
357    // go through the same executeUserInput loop. This ensures images get
358    // resized via processUserInput regardless of how the command arrives.
359    const cmd: QueuedCommand = {
360      value: finalInput,
361      preExpansionValue: input,
362      mode,
363      pastedContents: hasImages ? pastedContents : undefined,
364      skipSlashCommands,
365      uuid,
366    }
367  
368    await executeUserInput({
369      queuedCommands: [cmd],
370      messages,
371      mainLoopModel,
372      ideSelection,
373      querySource: params.querySource,
374      commands,
375      queryGuard,
376      setToolJSX,
377      getToolUseContext,
378      setUserInputOnProcessing,
379      setAbortController,
380      onQuery,
381      setAppState,
382      onBeforeQuery,
383      resetHistory,
384      canUseTool,
385      onInputChange,
386    })
387  }
388  
389  /**
390   * Core logic for executing user input without UI side effects.
391   *
392   * All commands arrive as `queuedCommands`. First command gets full treatment
393   * (attachments, ideSelection, pastedContents with image resizing). Commands 2-N
394   * get `skipAttachments` to avoid duplicating turn-level context.
395   */
396  async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
397    const {
398      messages,
399      mainLoopModel,
400      ideSelection,
401      querySource,
402      queryGuard,
403      setToolJSX,
404      getToolUseContext,
405      setUserInputOnProcessing,
406      setAbortController,
407      onQuery,
408      setAppState,
409      onBeforeQuery,
410      resetHistory,
411      canUseTool,
412      queuedCommands,
413    } = params
414  
415    // Note: paste references are already processed before calling this function
416    // (either in handlePromptSubmit before queuing, or before initial execution).
417    // Always create a fresh abort controller — queryGuard guarantees no concurrent
418    // executeUserInput call, so there's no prior controller to inherit.
419    const abortController = createAbortController()
420    setAbortController(abortController)
421  
422    function makeContext(): ProcessUserInputContext {
423      return getToolUseContext(messages, [], abortController, mainLoopModel)
424    }
425  
426    // Wrap in try-finally so the guard is released even if processUserInput
427    // throws or onQuery is skipped. onQuery's finally calls queryGuard.end(),
428    // which transitions running→idle; cancelReservation() below is a no-op in
429    // that case (only acts on dispatching state).
430    try {
431      // Reserve the guard BEFORE processUserInput — processBashCommand awaits
432      // BashTool.call() and processSlashCommand awaits getMessagesForSlashCommand,
433      // so the guard must be active during those awaits to ensure concurrent
434      // handlePromptSubmit calls queue (via the isActive check above) instead
435      // of starting a second executeUserInput. This call is a no-op if the
436      // guard is already in dispatching (legacy queue-processor path).
437      queryGuard.reserve()
438      queryCheckpoint('query_process_user_input_start')
439  
440      const newMessages: Message[] = []
441      let shouldQuery = false
442      let allowedTools: string[] | undefined
443      let model: string | undefined
444      let effort: EffortValue | undefined
445      let nextInput: string | undefined
446      let submitNextInput: boolean | undefined
447  
448      // Iterate all commands uniformly. First command gets attachments +
449      // ideSelection + pastedContents, rest skip attachments to avoid
450      // duplicating turn-level context (IDE selection, todos, diffs).
451      const commands = queuedCommands ?? []
452  
453      // Compute the workload tag for this turn. queueProcessor can batch a
454      // cron prompt with a same-tick human prompt; only tag when EVERY
455      // command agrees on the same non-undefined workload — a human in the
456      // mix is actively waiting.
457      const firstWorkload = commands[0]?.workload
458      const turnWorkload =
459        firstWorkload !== undefined &&
460        commands.every(c => c.workload === firstWorkload)
461          ? firstWorkload
462          : undefined
463  
464      // Wrap the entire turn (processUserInput loop + onQuery) in an
465      // AsyncLocalStorage context. This is the ONLY way to correctly
466      // propagate workload across await boundaries: void-detached bg agents
467      // (executeForkedSlashCommand, AgentTool) capture the ALS context at
468      // invocation time, and every await inside them resumes in that
469      // context — isolated from the parent's continuation. A process-global
470      // mutable slot would be clobbered at the detached closure's first
471      // await by this function's synchronous return path. See state.ts.
472      await runWithWorkload(turnWorkload, async () => {
473        for (let i = 0; i < commands.length; i++) {
474          const cmd = commands[i]!
475          const isFirst = i === 0
476          const result = await processUserInput({
477            input: cmd.value,
478            preExpansionInput: cmd.preExpansionValue,
479            mode: cmd.mode,
480            setToolJSX,
481            context: makeContext(),
482            pastedContents: isFirst ? cmd.pastedContents : undefined,
483            messages,
484            setUserInputOnProcessing: isFirst
485              ? setUserInputOnProcessing
486              : undefined,
487            isAlreadyProcessing: !isFirst,
488            querySource,
489            canUseTool,
490            uuid: cmd.uuid,
491            ideSelection: isFirst ? ideSelection : undefined,
492            skipSlashCommands: cmd.skipSlashCommands,
493            bridgeOrigin: cmd.bridgeOrigin,
494            isMeta: cmd.isMeta,
495            skipAttachments: !isFirst,
496          })
497          // Stamp origin here rather than threading another arg through
498          // processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
499          // Derive origin from mode for task-notifications — mirrors the origin
500          // derivation at messages.ts (case 'queued_command'); intentionally
501          // does NOT mirror its isMeta:true so idle-dequeued notifications stay
502          // visible in the transcript via UserAgentNotificationMessage.
503          const origin =
504            cmd.origin ??
505            (cmd.mode === 'task-notification'
506              ? ({ kind: 'task-notification' } as const)
507              : undefined)
508          if (origin) {
509            for (const m of result.messages) {
510              if (m.type === 'user') m.origin = origin
511            }
512          }
513          newMessages.push(...result.messages)
514          if (isFirst) {
515            shouldQuery = result.shouldQuery
516            allowedTools = result.allowedTools
517            model = result.model
518            effort = result.effort
519            nextInput = result.nextInput
520            submitNextInput = result.submitNextInput
521          }
522        }
523  
524        queryCheckpoint('query_process_user_input_end')
525        if (fileHistoryEnabled()) {
526          queryCheckpoint('query_file_history_snapshot_start')
527          newMessages.filter(selectableUserMessagesFilter).forEach(message => {
528            void fileHistoryMakeSnapshot(
529              (updater: (prev: FileHistoryState) => FileHistoryState) => {
530                setAppState(prev => ({
531                  ...prev,
532                  fileHistory: updater(prev.fileHistory),
533                }))
534              },
535              message.uuid,
536            )
537          })
538          queryCheckpoint('query_file_history_snapshot_end')
539        }
540  
541        if (newMessages.length) {
542          // History is now added in the caller (onSubmit) for direct user submissions.
543          // This ensures queued command processing (notifications, already-queued user input)
544          // doesn't add to history, since those either shouldn't be in history or were
545          // already added when originally queued.
546          resetHistory()
547          setToolJSX({
548            jsx: null,
549            shouldHidePromptInput: false,
550            clearLocalJSX: true,
551          })
552  
553          const primaryCmd = commands[0]
554          const primaryMode = primaryCmd?.mode ?? 'prompt'
555          const primaryInput =
556            primaryCmd && typeof primaryCmd.value === 'string'
557              ? primaryCmd.value
558              : undefined
559          const shouldCallBeforeQuery = primaryMode === 'prompt'
560          await onQuery(
561            newMessages,
562            abortController,
563            shouldQuery,
564            allowedTools ?? [],
565            model
566              ? resolveSkillModelOverride(model, mainLoopModel)
567              : mainLoopModel,
568            shouldCallBeforeQuery ? onBeforeQuery : undefined,
569            primaryInput,
570            effort,
571          )
572        } else {
573          // Local slash commands that skip messages (e.g., /model, /theme).
574          // Release the guard BEFORE clearing toolJSX to prevent spinner flash —
575          // the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
576          // If we clear toolJSX while the guard is still reserved, spinner briefly
577          // shows. The finally below also calls cancelReservation (no-op if idle).
578          queryGuard.cancelReservation()
579          setToolJSX({
580            jsx: null,
581            shouldHidePromptInput: false,
582            clearLocalJSX: true,
583          })
584          resetHistory()
585          setAbortController(null)
586        }
587  
588        // Handle nextInput from commands that want to chain (e.g., /discover activation)
589        if (nextInput) {
590          if (submitNextInput) {
591            enqueue({ value: nextInput, mode: 'prompt' })
592          } else {
593            params.onInputChange(nextInput)
594          }
595        }
596      }) // end runWithWorkload — ALS context naturally scoped, no finally needed
597    } finally {
598      // Safety net: release the guard reservation if processUserInput threw
599      // or onQuery was skipped. No-op if onQuery already ran (guard is idle
600      // via end(), or running — cancelReservation only acts on dispatching).
601      // This is the single source of truth for releasing the reservation;
602      // useQueueProcessor no longer needs its own .finally().
603      queryGuard.cancelReservation()
604      // Safety net: clear the placeholder if processUserInput produced no
605      // messages or threw — otherwise it would stay visible until the next
606      // turn's resetLoadingState. Harmless when onQuery ran: setMessages grew
607      // displayedMessages past the baseline, so REPL.tsx already hid it.
608      setUserInputOnProcessing(undefined)
609    }
610  }