init
This commit is contained in:
@@ -0,0 +1,610 @@
|
||||
import type { UUID } from 'crypto'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import type { AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS } from 'src/services/analytics/metadata.js'
|
||||
import { type Command, getCommandName, isCommandEnabled } from '../commands.js'
|
||||
import { selectableUserMessagesFilter } from '../components/MessageSelector.js'
|
||||
import type { SpinnerMode } from '../components/Spinner/types.js'
|
||||
import type { QuerySource } from '../constants/querySource.js'
|
||||
import { expandPastedTextRefs, parseReferences } from '../history.js'
|
||||
import type { CanUseToolFn } from '../hooks/useCanUseTool.js'
|
||||
import type { IDESelection } from '../hooks/useIdeSelection.js'
|
||||
import type { AppState } from '../state/AppState.js'
|
||||
import type { SetToolJSXFn } from '../Tool.js'
|
||||
import type { LocalJSXCommandOnDone } from '../types/command.js'
|
||||
import type { Message } from '../types/message.js'
|
||||
import {
|
||||
isValidImagePaste,
|
||||
type PromptInputMode,
|
||||
type QueuedCommand,
|
||||
} from '../types/textInputTypes.js'
|
||||
import { createAbortController } from './abortController.js'
|
||||
import type { PastedContent } from './config.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import type { EffortValue } from './effort.js'
|
||||
import type { FileHistoryState } from './fileHistory.js'
|
||||
import { fileHistoryEnabled, fileHistoryMakeSnapshot } from './fileHistory.js'
|
||||
import { gracefulShutdownSync } from './gracefulShutdown.js'
|
||||
import { enqueue } from './messageQueueManager.js'
|
||||
import { resolveSkillModelOverride } from './model/model.js'
|
||||
import type { ProcessUserInputContext } from './processUserInput/processUserInput.js'
|
||||
import { processUserInput } from './processUserInput/processUserInput.js'
|
||||
import type { QueryGuard } from './QueryGuard.js'
|
||||
import { queryCheckpoint, startQueryProfile } from './queryProfiler.js'
|
||||
import { runWithWorkload } from './workloadContext.js'
|
||||
|
||||
function exit(): void {
|
||||
gracefulShutdownSync(0)
|
||||
}
|
||||
|
||||
type BaseExecutionParams = {
|
||||
queuedCommands?: QueuedCommand[]
|
||||
messages: Message[]
|
||||
mainLoopModel: string
|
||||
ideSelection: IDESelection | undefined
|
||||
querySource: QuerySource
|
||||
commands: Command[]
|
||||
queryGuard: QueryGuard
|
||||
/**
|
||||
* True when external loading (remote session, foregrounded background task)
|
||||
* is active. These don't route through queryGuard, so the queue check must
|
||||
* account for them separately. Omit (defaults to false) for the dequeue path
|
||||
* (executeQueuedInput) — dequeued items were already queued past this check.
|
||||
*/
|
||||
isExternalLoading?: boolean
|
||||
setToolJSX: SetToolJSXFn
|
||||
getToolUseContext: (
|
||||
messages: Message[],
|
||||
newMessages: Message[],
|
||||
abortController: AbortController,
|
||||
mainLoopModel: string,
|
||||
) => ProcessUserInputContext
|
||||
setUserInputOnProcessing: (prompt?: string) => void
|
||||
setAbortController: (abortController: AbortController | null) => void
|
||||
onQuery: (
|
||||
newMessages: Message[],
|
||||
abortController: AbortController,
|
||||
shouldQuery: boolean,
|
||||
additionalAllowedTools: string[],
|
||||
mainLoopModel: string,
|
||||
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>,
|
||||
input?: string,
|
||||
effort?: EffortValue,
|
||||
) => Promise<void>
|
||||
setAppState: (updater: (prev: AppState) => AppState) => void
|
||||
onBeforeQuery?: (input: string, newMessages: Message[]) => Promise<boolean>
|
||||
canUseTool?: CanUseToolFn
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameters for core execution logic (no UI concerns).
|
||||
*/
|
||||
type ExecuteUserInputParams = BaseExecutionParams & {
|
||||
resetHistory: () => void
|
||||
onInputChange: (value: string) => void
|
||||
}
|
||||
|
||||
export type PromptInputHelpers = {
|
||||
setCursorOffset: (offset: number) => void
|
||||
clearBuffer: () => void
|
||||
resetHistory: () => void
|
||||
}
|
||||
|
||||
export type HandlePromptSubmitParams = BaseExecutionParams & {
|
||||
// Direct user input path (set when called from onSubmit, absent for queue processor)
|
||||
input?: string
|
||||
mode?: PromptInputMode
|
||||
pastedContents?: Record<number, PastedContent>
|
||||
helpers: PromptInputHelpers
|
||||
onInputChange: (value: string) => void
|
||||
setPastedContents: React.Dispatch<
|
||||
React.SetStateAction<Record<number, PastedContent>>
|
||||
>
|
||||
abortController?: AbortController | null
|
||||
addNotification?: (notification: {
|
||||
key: string
|
||||
text: string
|
||||
priority: 'low' | 'medium' | 'high' | 'immediate'
|
||||
}) => void
|
||||
setMessages?: (updater: (prev: Message[]) => Message[]) => void
|
||||
streamMode?: SpinnerMode
|
||||
hasInterruptibleToolInProgress?: boolean
|
||||
uuid?: UUID
|
||||
/**
|
||||
* When true, input starting with `/` is treated as plain text.
|
||||
* Used for remotely-received messages (bridge/CCR) that should not
|
||||
* trigger local slash commands or skills.
|
||||
*/
|
||||
skipSlashCommands?: boolean
|
||||
}
|
||||
|
||||
export async function handlePromptSubmit(
|
||||
params: HandlePromptSubmitParams,
|
||||
): Promise<void> {
|
||||
const {
|
||||
helpers,
|
||||
queryGuard,
|
||||
isExternalLoading = false,
|
||||
commands,
|
||||
onInputChange,
|
||||
setPastedContents,
|
||||
setToolJSX,
|
||||
getToolUseContext,
|
||||
messages,
|
||||
mainLoopModel,
|
||||
ideSelection,
|
||||
setUserInputOnProcessing,
|
||||
setAbortController,
|
||||
onQuery,
|
||||
setAppState,
|
||||
onBeforeQuery,
|
||||
canUseTool,
|
||||
queuedCommands,
|
||||
uuid,
|
||||
skipSlashCommands,
|
||||
} = params
|
||||
|
||||
const { setCursorOffset, clearBuffer, resetHistory } = helpers
|
||||
|
||||
// Queue processor path: commands are pre-validated and ready to execute.
|
||||
// Skip all input validation, reference parsing, and queuing logic.
|
||||
if (queuedCommands?.length) {
|
||||
startQueryProfile()
|
||||
await executeUserInput({
|
||||
queuedCommands,
|
||||
messages,
|
||||
mainLoopModel,
|
||||
ideSelection,
|
||||
querySource: params.querySource,
|
||||
commands,
|
||||
queryGuard,
|
||||
setToolJSX,
|
||||
getToolUseContext,
|
||||
setUserInputOnProcessing,
|
||||
setAbortController,
|
||||
onQuery,
|
||||
setAppState,
|
||||
onBeforeQuery,
|
||||
resetHistory,
|
||||
canUseTool,
|
||||
onInputChange,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const input = params.input ?? ''
|
||||
const mode = params.mode ?? 'prompt'
|
||||
const rawPastedContents = params.pastedContents ?? {}
|
||||
|
||||
// Images are only sent if their [Image #N] placeholder is still in the text.
|
||||
// Deleting the inline pill drops the image; orphaned entries are filtered here.
|
||||
const referencedIds = new Set(parseReferences(input).map(r => r.id))
|
||||
const pastedContents = Object.fromEntries(
|
||||
Object.entries(rawPastedContents).filter(
|
||||
([, c]) => c.type !== 'image' || referencedIds.has(c.id),
|
||||
),
|
||||
)
|
||||
|
||||
const hasImages = Object.values(pastedContents).some(isValidImagePaste)
|
||||
if (input.trim() === '') {
|
||||
return
|
||||
}
|
||||
|
||||
// Handle exit commands by triggering the exit command instead of direct process.exit
|
||||
// Skip for remote bridge messages — "exit" typed on iOS shouldn't kill the local session
|
||||
if (
|
||||
!skipSlashCommands &&
|
||||
['exit', 'quit', ':q', ':q!', ':wq', ':wq!'].includes(input.trim())
|
||||
) {
|
||||
// Trigger the exit command which will show the feedback dialog
|
||||
const exitCommand = commands.find(cmd => cmd.name === 'exit')
|
||||
if (exitCommand) {
|
||||
// Submit the /exit command instead - recursive call needs to be handled
|
||||
void handlePromptSubmit({
|
||||
...params,
|
||||
input: '/exit',
|
||||
})
|
||||
} else {
|
||||
// Fallback to direct exit if exit command not found
|
||||
exit()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Parse references and replace with actual content early, before queueing
|
||||
// or immediate-command dispatch, so queued commands and immediate commands
|
||||
// both receive the expanded text from when it was submitted.
|
||||
const finalInput = expandPastedTextRefs(input, pastedContents)
|
||||
const pastedTextRefs = parseReferences(input).filter(
|
||||
r => pastedContents[r.id]?.type === 'text',
|
||||
)
|
||||
const pastedTextCount = pastedTextRefs.length
|
||||
const pastedTextBytes = pastedTextRefs.reduce(
|
||||
(sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0),
|
||||
0,
|
||||
)
|
||||
logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes })
|
||||
|
||||
// Handle local-jsx immediate commands (e.g., /config, /doctor)
|
||||
// Skip for remote bridge messages — slash commands from CCR clients are plain text
|
||||
if (!skipSlashCommands && finalInput.trim().startsWith('/')) {
|
||||
const trimmedInput = finalInput.trim()
|
||||
const spaceIndex = trimmedInput.indexOf(' ')
|
||||
const commandName =
|
||||
spaceIndex === -1
|
||||
? trimmedInput.slice(1)
|
||||
: trimmedInput.slice(1, spaceIndex)
|
||||
const commandArgs =
|
||||
spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim()
|
||||
|
||||
const immediateCommand = commands.find(
|
||||
cmd =>
|
||||
cmd.immediate &&
|
||||
isCommandEnabled(cmd) &&
|
||||
(cmd.name === commandName ||
|
||||
cmd.aliases?.includes(commandName) ||
|
||||
getCommandName(cmd) === commandName),
|
||||
)
|
||||
|
||||
if (
|
||||
immediateCommand &&
|
||||
immediateCommand.type === 'local-jsx' &&
|
||||
(queryGuard.isActive || isExternalLoading)
|
||||
) {
|
||||
logEvent('tengu_immediate_command_executed', {
|
||||
commandName:
|
||||
immediateCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
|
||||
// Clear input
|
||||
onInputChange('')
|
||||
setCursorOffset(0)
|
||||
setPastedContents({})
|
||||
clearBuffer()
|
||||
|
||||
const context = getToolUseContext(
|
||||
messages,
|
||||
[],
|
||||
createAbortController(),
|
||||
mainLoopModel,
|
||||
)
|
||||
|
||||
let doneWasCalled = false
|
||||
const onDone: LocalJSXCommandOnDone = (result, options) => {
|
||||
doneWasCalled = true
|
||||
// Use clearLocalJSX to explicitly clear the local JSX command
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
if (result && options?.display !== 'skip' && params.addNotification) {
|
||||
params.addNotification({
|
||||
key: `immediate-${immediateCommand.name}`,
|
||||
text: result,
|
||||
priority: 'immediate',
|
||||
})
|
||||
}
|
||||
if (options?.nextInput) {
|
||||
if (options.submitNextInput) {
|
||||
enqueue({ value: options.nextInput, mode: 'prompt' })
|
||||
} else {
|
||||
onInputChange(options.nextInput)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const impl = await immediateCommand.load()
|
||||
const jsx = await impl.call(onDone, context, commandArgs)
|
||||
|
||||
// Skip if onDone already fired — prevents stuck isLocalJSXCommand
|
||||
// (see processSlashCommand.tsx local-jsx case for full mechanism).
|
||||
if (jsx && !doneWasCalled) {
|
||||
setToolJSX({
|
||||
jsx,
|
||||
shouldHidePromptInput: false,
|
||||
isLocalJSXCommand: true,
|
||||
isImmediate: true,
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (queryGuard.isActive || isExternalLoading) {
|
||||
// Only allow prompt and bash mode commands to be queued
|
||||
if (mode !== 'prompt' && mode !== 'bash') {
|
||||
return
|
||||
}
|
||||
|
||||
// Interrupt the current turn when all executing tools have
|
||||
// interruptBehavior 'cancel' (e.g. SleepTool).
|
||||
if (params.hasInterruptibleToolInProgress) {
|
||||
logForDebugging(
|
||||
`[interrupt] Aborting current turn: streamMode=${params.streamMode}`,
|
||||
)
|
||||
logEvent('tengu_cancel', {
|
||||
source:
|
||||
'interrupt_on_submit' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
streamMode:
|
||||
params.streamMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
|
||||
})
|
||||
params.abortController?.abort('interrupt')
|
||||
}
|
||||
|
||||
// Enqueue with string value + raw pastedContents. Images will be resized
|
||||
// at execution time when processUserInput runs (not baked in here).
|
||||
enqueue({
|
||||
value: finalInput.trim(),
|
||||
preExpansionValue: input.trim(),
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
uuid,
|
||||
})
|
||||
|
||||
onInputChange('')
|
||||
setCursorOffset(0)
|
||||
setPastedContents({})
|
||||
resetHistory()
|
||||
clearBuffer()
|
||||
return
|
||||
}
|
||||
|
||||
// Start query profiling for this query
|
||||
startQueryProfile()
|
||||
|
||||
// Construct a QueuedCommand from the direct user input so both paths
|
||||
// go through the same executeUserInput loop. This ensures images get
|
||||
// resized via processUserInput regardless of how the command arrives.
|
||||
const cmd: QueuedCommand = {
|
||||
value: finalInput,
|
||||
preExpansionValue: input,
|
||||
mode,
|
||||
pastedContents: hasImages ? pastedContents : undefined,
|
||||
skipSlashCommands,
|
||||
uuid,
|
||||
}
|
||||
|
||||
await executeUserInput({
|
||||
queuedCommands: [cmd],
|
||||
messages,
|
||||
mainLoopModel,
|
||||
ideSelection,
|
||||
querySource: params.querySource,
|
||||
commands,
|
||||
queryGuard,
|
||||
setToolJSX,
|
||||
getToolUseContext,
|
||||
setUserInputOnProcessing,
|
||||
setAbortController,
|
||||
onQuery,
|
||||
setAppState,
|
||||
onBeforeQuery,
|
||||
resetHistory,
|
||||
canUseTool,
|
||||
onInputChange,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Core logic for executing user input without UI side effects.
|
||||
*
|
||||
* All commands arrive as `queuedCommands`. First command gets full treatment
|
||||
* (attachments, ideSelection, pastedContents with image resizing). Commands 2-N
|
||||
* get `skipAttachments` to avoid duplicating turn-level context.
|
||||
*/
|
||||
async function executeUserInput(params: ExecuteUserInputParams): Promise<void> {
|
||||
const {
|
||||
messages,
|
||||
mainLoopModel,
|
||||
ideSelection,
|
||||
querySource,
|
||||
queryGuard,
|
||||
setToolJSX,
|
||||
getToolUseContext,
|
||||
setUserInputOnProcessing,
|
||||
setAbortController,
|
||||
onQuery,
|
||||
setAppState,
|
||||
onBeforeQuery,
|
||||
resetHistory,
|
||||
canUseTool,
|
||||
queuedCommands,
|
||||
} = params
|
||||
|
||||
// Note: paste references are already processed before calling this function
|
||||
// (either in handlePromptSubmit before queuing, or before initial execution).
|
||||
// Always create a fresh abort controller — queryGuard guarantees no concurrent
|
||||
// executeUserInput call, so there's no prior controller to inherit.
|
||||
const abortController = createAbortController()
|
||||
setAbortController(abortController)
|
||||
|
||||
function makeContext(): ProcessUserInputContext {
|
||||
return getToolUseContext(messages, [], abortController, mainLoopModel)
|
||||
}
|
||||
|
||||
// Wrap in try-finally so the guard is released even if processUserInput
|
||||
// throws or onQuery is skipped. onQuery's finally calls queryGuard.end(),
|
||||
// which transitions running→idle; cancelReservation() below is a no-op in
|
||||
// that case (only acts on dispatching state).
|
||||
try {
|
||||
// Reserve the guard BEFORE processUserInput — processBashCommand awaits
|
||||
// BashTool.call() and processSlashCommand awaits getMessagesForSlashCommand,
|
||||
// so the guard must be active during those awaits to ensure concurrent
|
||||
// handlePromptSubmit calls queue (via the isActive check above) instead
|
||||
// of starting a second executeUserInput. This call is a no-op if the
|
||||
// guard is already in dispatching (legacy queue-processor path).
|
||||
queryGuard.reserve()
|
||||
queryCheckpoint('query_process_user_input_start')
|
||||
|
||||
const newMessages: Message[] = []
|
||||
let shouldQuery = false
|
||||
let allowedTools: string[] | undefined
|
||||
let model: string | undefined
|
||||
let effort: EffortValue | undefined
|
||||
let nextInput: string | undefined
|
||||
let submitNextInput: boolean | undefined
|
||||
|
||||
// Iterate all commands uniformly. First command gets attachments +
|
||||
// ideSelection + pastedContents, rest skip attachments to avoid
|
||||
// duplicating turn-level context (IDE selection, todos, diffs).
|
||||
const commands = queuedCommands ?? []
|
||||
|
||||
// Compute the workload tag for this turn. queueProcessor can batch a
|
||||
// cron prompt with a same-tick human prompt; only tag when EVERY
|
||||
// command agrees on the same non-undefined workload — a human in the
|
||||
// mix is actively waiting.
|
||||
const firstWorkload = commands[0]?.workload
|
||||
const turnWorkload =
|
||||
firstWorkload !== undefined &&
|
||||
commands.every(c => c.workload === firstWorkload)
|
||||
? firstWorkload
|
||||
: undefined
|
||||
|
||||
// Wrap the entire turn (processUserInput loop + onQuery) in an
|
||||
// AsyncLocalStorage context. This is the ONLY way to correctly
|
||||
// propagate workload across await boundaries: void-detached bg agents
|
||||
// (executeForkedSlashCommand, AgentTool) capture the ALS context at
|
||||
// invocation time, and every await inside them resumes in that
|
||||
// context — isolated from the parent's continuation. A process-global
|
||||
// mutable slot would be clobbered at the detached closure's first
|
||||
// await by this function's synchronous return path. See state.ts.
|
||||
await runWithWorkload(turnWorkload, async () => {
|
||||
for (let i = 0; i < commands.length; i++) {
|
||||
const cmd = commands[i]!
|
||||
const isFirst = i === 0
|
||||
const result = await processUserInput({
|
||||
input: cmd.value,
|
||||
preExpansionInput: cmd.preExpansionValue,
|
||||
mode: cmd.mode,
|
||||
setToolJSX,
|
||||
context: makeContext(),
|
||||
pastedContents: isFirst ? cmd.pastedContents : undefined,
|
||||
messages,
|
||||
setUserInputOnProcessing: isFirst
|
||||
? setUserInputOnProcessing
|
||||
: undefined,
|
||||
isAlreadyProcessing: !isFirst,
|
||||
querySource,
|
||||
canUseTool,
|
||||
uuid: cmd.uuid,
|
||||
ideSelection: isFirst ? ideSelection : undefined,
|
||||
skipSlashCommands: cmd.skipSlashCommands,
|
||||
bridgeOrigin: cmd.bridgeOrigin,
|
||||
isMeta: cmd.isMeta,
|
||||
skipAttachments: !isFirst,
|
||||
})
|
||||
// Stamp origin here rather than threading another arg through
|
||||
// processUserInput → processUserInputBase → processTextPrompt → createUserMessage.
|
||||
// Derive origin from mode for task-notifications — mirrors the origin
|
||||
// derivation at messages.ts (case 'queued_command'); intentionally
|
||||
// does NOT mirror its isMeta:true so idle-dequeued notifications stay
|
||||
// visible in the transcript via UserAgentNotificationMessage.
|
||||
const origin =
|
||||
cmd.origin ??
|
||||
(cmd.mode === 'task-notification'
|
||||
? ({ kind: 'task-notification' } as const)
|
||||
: undefined)
|
||||
if (origin) {
|
||||
for (const m of result.messages) {
|
||||
if (m.type === 'user') m.origin = origin
|
||||
}
|
||||
}
|
||||
newMessages.push(...result.messages)
|
||||
if (isFirst) {
|
||||
shouldQuery = result.shouldQuery
|
||||
allowedTools = result.allowedTools
|
||||
model = result.model
|
||||
effort = result.effort
|
||||
nextInput = result.nextInput
|
||||
submitNextInput = result.submitNextInput
|
||||
}
|
||||
}
|
||||
|
||||
queryCheckpoint('query_process_user_input_end')
|
||||
if (fileHistoryEnabled()) {
|
||||
queryCheckpoint('query_file_history_snapshot_start')
|
||||
newMessages.filter(selectableUserMessagesFilter).forEach(message => {
|
||||
void fileHistoryMakeSnapshot(
|
||||
(updater: (prev: FileHistoryState) => FileHistoryState) => {
|
||||
setAppState(prev => ({
|
||||
...prev,
|
||||
fileHistory: updater(prev.fileHistory),
|
||||
}))
|
||||
},
|
||||
message.uuid,
|
||||
)
|
||||
})
|
||||
queryCheckpoint('query_file_history_snapshot_end')
|
||||
}
|
||||
|
||||
if (newMessages.length) {
|
||||
// History is now added in the caller (onSubmit) for direct user submissions.
|
||||
// This ensures queued command processing (notifications, already-queued user input)
|
||||
// doesn't add to history, since those either shouldn't be in history or were
|
||||
// already added when originally queued.
|
||||
resetHistory()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
|
||||
const primaryCmd = commands[0]
|
||||
const primaryMode = primaryCmd?.mode ?? 'prompt'
|
||||
const primaryInput =
|
||||
primaryCmd && typeof primaryCmd.value === 'string'
|
||||
? primaryCmd.value
|
||||
: undefined
|
||||
const shouldCallBeforeQuery = primaryMode === 'prompt'
|
||||
await onQuery(
|
||||
newMessages,
|
||||
abortController,
|
||||
shouldQuery,
|
||||
allowedTools ?? [],
|
||||
model
|
||||
? resolveSkillModelOverride(model, mainLoopModel)
|
||||
: mainLoopModel,
|
||||
shouldCallBeforeQuery ? onBeforeQuery : undefined,
|
||||
primaryInput,
|
||||
effort,
|
||||
)
|
||||
} else {
|
||||
// Local slash commands that skip messages (e.g., /model, /theme).
|
||||
// Release the guard BEFORE clearing toolJSX to prevent spinner flash —
|
||||
// the spinner formula checks: (!toolJSX || showSpinner) && isLoading.
|
||||
// If we clear toolJSX while the guard is still reserved, spinner briefly
|
||||
// shows. The finally below also calls cancelReservation (no-op if idle).
|
||||
queryGuard.cancelReservation()
|
||||
setToolJSX({
|
||||
jsx: null,
|
||||
shouldHidePromptInput: false,
|
||||
clearLocalJSX: true,
|
||||
})
|
||||
resetHistory()
|
||||
setAbortController(null)
|
||||
}
|
||||
|
||||
// Handle nextInput from commands that want to chain (e.g., /discover activation)
|
||||
if (nextInput) {
|
||||
if (submitNextInput) {
|
||||
enqueue({ value: nextInput, mode: 'prompt' })
|
||||
} else {
|
||||
params.onInputChange(nextInput)
|
||||
}
|
||||
}
|
||||
}) // end runWithWorkload — ALS context naturally scoped, no finally needed
|
||||
} finally {
|
||||
// Safety net: release the guard reservation if processUserInput threw
|
||||
// or onQuery was skipped. No-op if onQuery already ran (guard is idle
|
||||
// via end(), or running — cancelReservation only acts on dispatching).
|
||||
// This is the single source of truth for releasing the reservation;
|
||||
// useQueueProcessor no longer needs its own .finally().
|
||||
queryGuard.cancelReservation()
|
||||
// Safety net: clear the placeholder if processUserInput produced no
|
||||
// messages or threw — otherwise it would stay visible until the next
|
||||
// turn's resetLoadingState. Harmless when onQuery ran: setMessages grew
|
||||
// displayedMessages past the baseline, so REPL.tsx already hid it.
|
||||
setUserInputOnProcessing(undefined)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user