/**
 * @license
 * Copyright 2025 Google LLC
 * SPDX-License-Identifier: Apache-2.0
 */

import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
import {
  GeminiEventType as ServerGeminiEventType,
  getErrorMessage,
  isNodeError,
  MessageSenderType,
  logUserPrompt,
  GitService,
  UnauthorizedError,
  UserPromptEvent,
  DEFAULT_GEMINI_FLASH_MODEL,
  logConversationFinishedEvent,
  ConversationFinishedEvent,
  ApprovalMode,
  parseAndFormatApiError,
  ToolConfirmationOutcome,
  MessageBusType,
  promptIdContext,
  tokenLimit,
  debugLogger,
  runInDevTraceSpan,
  EDIT_TOOL_NAMES,
  processRestorableToolCalls,
  recordToolCallInteractions,
  ToolErrorType,
  ValidationRequiredError,
  coreEvents,
  CoreEvent,
  CoreToolCallStatus,
  buildUserSteeringHintPrompt,
  GeminiCliOperation,
  getPlanModeExitMessage,
  isBackgroundExecutionData,
  Kind,
  ACTIVATE_SKILL_TOOL_NAME,
  isRenderedInHistory,
  buildToolVisibilityContext,
  UPDATE_TOPIC_TOOL_NAME,
  UPDATE_TOPIC_DISPLAY_NAME,
} from '@google/gemini-cli-core';
import type {
  Config,
  EditorType,
  GeminiClient,
  ServerGeminiChatCompressedEvent,
  ServerGeminiContentEvent as ContentEvent,
  ServerGeminiFinishedEvent,
  ServerGeminiStreamEvent as GeminiEvent,
  ThoughtSummary,
  ToolCallRequestInfo,
  ToolCallResponseInfo,
  GeminiErrorEventValue,
  RetryAttemptPayload,
} from '@google/gemini-cli-core';
import { type Part, type PartListUnion, FinishReason } from '@google/genai';
import type {
  HistoryItem,
  HistoryItemThinking,
  HistoryItemWithoutId,
  HistoryItemToolGroup,
  HistoryItemInfo,
  IndividualToolCallDisplay,
  SlashCommandProcessorResult,
  HistoryItemModel,
} from '../types.js';
import {
  StreamingState,
  MessageType,
  mapCoreStatusToDisplayStatus,
  ToolCallStatus,
} from '../types.js';
import { isAtCommand, isSlashCommand } from '../utils/commandUtils.js';
import { useExecutionLifecycle } from './useExecutionLifecycle.js';
import { handleAtCommand } from './atCommandProcessor.js';
import { findLastSafeSplitPoint } from '../utils/markdownUtilities.js';
import { getInlineThinkingMode } from '../utils/inlineThinkingMode.js';
import { useStateAndRef } from './useStateAndRef.js';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { useLogger } from './useLogger.js';
import { SHELL_COMMAND_NAME } from '../constants.js';
import { mapToDisplay as mapTrackedToolCallsToDisplay } from './toolMapping.js';
import { isCompactTool } from '../components/messages/ToolGroupMessage.js';
import {
  useToolScheduler,
  type TrackedToolCall,
  type TrackedCompletedToolCall,
  type TrackedCancelledToolCall,
  type TrackedWaitingToolCall,
  type TrackedExecutingToolCall,
} from './useToolScheduler.js';
import { theme } from '../semantic-colors.js';
import { getToolGroupBorderAppearance } from '../utils/borderStyles.js';
import { promises as fs } from 'node:fs';
import path from 'node:path';
import { useSessionStats } from '../contexts/SessionContext.js';
import { useKeypress } from './useKeypress.js';
import type { LoadedSettings } from '../../config/settings.js';

type ToolResponseWithParts = ToolCallResponseInfo & {
  llmContent?: PartListUnion;
};

interface BackgroundedToolInfo {
  pid: number;
  command: string;
  initialOutput: string;
}

const isTopicTool = (name: string): boolean =>
  name === UPDATE_TOPIC_TOOL_NAME || name === UPDATE_TOPIC_DISPLAY_NAME;

enum StreamProcessingStatus {
  Completed,
  UserCancelled,
  Error,
}

const SUPPRESSED_TOOL_ERRORS_NOTE =
  'Some internal tool attempts failed before this final error. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for details.';
const LOW_VERBOSITY_FAILURE_NOTE =
  'This request failed. Press F12 for diagnostics, or run /settings and change "Error Verbosity" to full for full details.';

function getBackgroundedToolInfo(
  toolCall: TrackedCompletedToolCall | TrackedCancelledToolCall,
): BackgroundedToolInfo | undefined {
  const response = toolCall.response as ToolResponseWithParts;
  const rawData: unknown = response?.data;
  if (!isBackgroundExecutionData(rawData)) {
    return undefined;
  }

  if (rawData.pid === undefined) {
    return undefined;
  }

  return {
    pid: rawData.pid,
    command: rawData.command ?? toolCall.request.name,
    initialOutput: rawData.initialOutput ?? '',
  };
}

function isBackgroundableExecutingToolCall(
  toolCall: TrackedToolCall,
): toolCall is TrackedExecutingToolCall {
  return (
    toolCall.status === CoreToolCallStatus.Executing &&
    typeof toolCall.pid === 'number'
  );
}

function showCitations(settings: LoadedSettings): boolean {
  const enabled = settings.merged.ui.showCitations;
  if (enabled !== undefined) {
    return enabled;
  }
  return true;
}

/**
 * Calculates the current streaming state based on tool call status and responding flag.
 */
function calculateStreamingState(
  isResponding: boolean,
  toolCalls: TrackedToolCall[],
): StreamingState {
  if (
    toolCalls.some((tc) => tc.status === CoreToolCallStatus.AwaitingApproval)
  ) {
    return StreamingState.WaitingForConfirmation;
  }

  const isAnyToolActive = toolCalls.some((tc) => {
    // These statuses indicate active processing
    if (
      tc.status === CoreToolCallStatus.Executing ||
      tc.status === CoreToolCallStatus.Scheduled ||
      tc.status === CoreToolCallStatus.Validating
    ) {
      return true;
    }

    // Terminal statuses (success, error, cancelled) still count as "Responding"
    // if the result hasn't been submitted back to Gemini yet.
    if (
      tc.status === CoreToolCallStatus.Success ||
      tc.status === CoreToolCallStatus.Error ||
      tc.status === CoreToolCallStatus.Cancelled
    ) {
      return !(tc as TrackedCompletedToolCall | TrackedCancelledToolCall)
        .responseSubmittedToGemini;
    }

    return false;
  });

  if (isResponding || isAnyToolActive) {
    return StreamingState.Responding;
  }

  return StreamingState.Idle;
}

/**
 * Manages the Gemini stream, including user input, command processing,
 * API interaction, and tool call lifecycle.
 */
export const useGeminiStream = (
  geminiClient: GeminiClient,
  history: HistoryItem[],
  addItem: UseHistoryManagerReturn['addItem'],
  config: Config,
  settings: LoadedSettings,
  onDebugMessage: (message: string) => void,
  handleSlashCommand: (
    cmd: PartListUnion,
  ) => Promise<SlashCommandProcessorResult | false>,
  shellModeActive: boolean,
  getPreferredEditor: () => EditorType | undefined,
  onAuthError: (error: string) => void,
  performMemoryRefresh: () => Promise<void>,
  modelSwitchedFromQuotaError: boolean,
  setModelSwitchedFromQuotaError: React.Dispatch<React.SetStateAction<boolean>>,
  onCancelSubmit: (
    shouldRestorePrompt?: boolean,
    clearBuffer?: boolean,
  ) => void,
  setShellInputFocused: (value: boolean) => void,
  terminalWidth: number,
  terminalHeight: number,
  isShellFocused?: boolean,
  consumeUserHint?: () => string | null,
) => {
  const [initError, setInitError] = useState<string | null>(null);
  const [retryStatus, setRetryStatus] = useState<RetryAttemptPayload | null>(
    null,
  );
  const isLowErrorVerbosity = settings.merged.ui?.errorVerbosity !== 'full';
  const suppressedToolErrorCountRef = useRef(0);
  const suppressedToolErrorNoteShownRef = useRef(false);
  const lowVerbosityFailureNoteShownRef = useRef(false);
  const abortControllerRef = useRef<AbortController | null>(null);
  const turnCancelledRef = useRef(false);
  const activeQueryIdRef = useRef<string | null>(null);
  const previousApprovalModeRef = useRef<ApprovalMode>(
    config.getApprovalMode(),
  );
  const [isResponding, isRespondingRef, setIsResponding] =
    useStateAndRef<boolean>(false);
  const [thought, thoughtRef, setThought] =
    useStateAndRef<ThoughtSummary | null>(null);
  const [pendingHistoryItem, pendingHistoryItemRef, setPendingHistoryItem] =
    useStateAndRef<HistoryItemWithoutId | null>(null);

  const [lastGeminiActivityTime, setLastGeminiActivityTime] =
    useState<number>(0);
  const [pushedToolCallIds, pushedToolCallIdsRef, setPushedToolCallIds] =
    useStateAndRef<Set<string>>(new Set());
  const [_isFirstToolInGroup, isFirstToolInGroupRef, setIsFirstToolInGroup] =
    useStateAndRef<boolean>(true);
  const processedMemoryToolsRef = useRef<Set<string>>(new Set());
  const { startNewPrompt, getPromptCount } = useSessionStats();
  const logger = useLogger(config);
  const gitService = useMemo(() => {
    if (!config.getProjectRoot()) {
      return;
    }
    return new GitService(config.getProjectRoot(), config.storage);
  }, [config]);

  useEffect(() => {
    const handleRetryAttempt = (payload: RetryAttemptPayload) => {
      if (turnCancelledRef.current || !isRespondingRef.current) {
        return;
      }
      setRetryStatus(payload);
    };
    coreEvents.on(CoreEvent.RetryAttempt, handleRetryAttempt);
    return () => {
      coreEvents.off(CoreEvent.RetryAttempt, handleRetryAttempt);
    };
  }, [isRespondingRef]);

  const [
    toolCalls,
    scheduleToolCalls,
    markToolsAsSubmitted,
    setToolCallsForDisplay,
    cancelAllToolCalls,
    lastToolOutputTime,
  ] = useToolScheduler(
    async (completedToolCallsFromScheduler) => {
      // This onComplete is called when ALL scheduled tools for a given batch are done.
      if (completedToolCallsFromScheduler.length > 0) {
        // Add only the tools that haven't been pushed to history yet.
        const toolsToPush = completedToolCallsFromScheduler.filter(
          (tc) => !pushedToolCallIdsRef.current.has(tc.request.callId),
        );
        if (toolsToPush.length > 0) {
          const isCompactModeEnabled =
            settings.merged.ui?.compactToolOutput === true;
          const firstToolToPush = toolsToPush[0];
          const tcIndex = toolCalls.indexOf(firstToolToPush);
          const prevTool = tcIndex > 0 ? toolCalls[tcIndex - 1] : null;

          let borderTop = isFirstToolInGroupRef.current;
          if (!borderTop && prevTool) {
            // If the first tool in this push is non-compact but follows a compact tool,
            // we must start a new border group.
            const currentIsCompact = isCompactTool(
              mapTrackedToolCallsToDisplay(firstToolToPush).tools[0],
              isCompactModeEnabled,
            );
            const prevWasCompact = isCompactTool(
              mapTrackedToolCallsToDisplay(prevTool).tools[0],
              isCompactModeEnabled,
            );
            if (!currentIsCompact && prevWasCompact) {
              borderTop = true;
            }
          }

          addItem(
            mapTrackedToolCallsToDisplay(toolsToPush as TrackedToolCall[], {
              borderTop,
              borderBottom: true,
              borderColor: theme.border.default,
              borderDimColor: false,
            }),
          );
        }

        // Clear the live-updating display now that the final state is in history.
        setToolCallsForDisplay([]);

        // Record tool calls with full metadata before sending responses.
        try {
          const currentModel =
            config.getGeminiClient().getCurrentSequenceModel() ??
            config.getModel();
          config
            .getGeminiClient()
            .getChat()
            .recordCompletedToolCalls(
              currentModel,
              completedToolCallsFromScheduler,
            );

          await recordToolCallInteractions(
            config,
            completedToolCallsFromScheduler,
          );
        } catch (error) {
          debugLogger.warn(
            `Error recording completed tool call information: ${error}`,
          );
        }

        // Handle tool response submission immediately when tools complete
        await handleCompletedTools(completedToolCallsFromScheduler);
      }
    },
    config,
    getPreferredEditor,
  );

  const activeBackgroundExecutionId = useMemo(() => {
    const executingBackgroundableTool = toolCalls.find(
      isBackgroundableExecutingToolCall,
    );
    return executingBackgroundableTool?.pid;
  }, [toolCalls]);

  const onExec = useCallback(
    async (done: Promise<void>) => {
      setIsResponding(true);
      await done;
      setIsResponding(false);
    },
    [setIsResponding],
  );

  const {
    handleShellCommand,
    activeShellPtyId,
    lastShellOutputTime,
    backgroundTaskCount,
    isBackgroundTaskVisible,
    toggleBackgroundTasks,
    backgroundCurrentExecution,
    registerBackgroundTask,
    dismissBackgroundTask,
    backgroundTasks,
  } = useExecutionLifecycle(
    addItem,
    setPendingHistoryItem,
    onExec,
    onDebugMessage,
    config,
    geminiClient,
    setShellInputFocused,
    terminalWidth,
    terminalHeight,
    activeBackgroundExecutionId,
  );

  const streamingState = useMemo(
    () => calculateStreamingState(isResponding, toolCalls),
    [isResponding, toolCalls],
  );

  // Reset tracking when a new batch of tools starts
  useEffect(() => {
    if (toolCalls.length > 0) {
      const isNewBatch = !toolCalls.some((tc) =>
        pushedToolCallIdsRef.current.has(tc.request.callId),
      );
      if (isNewBatch) {
        setPushedToolCallIds(new Set());
        setIsFirstToolInGroup(true);
      }
    } else if (streamingState === StreamingState.Idle) {
      // Clear when idle to be ready for next turn
      setPushedToolCallIds(new Set());
      setIsFirstToolInGroup(true);
    }
  }, [
    toolCalls,
    pushedToolCallIdsRef,
    setPushedToolCallIds,
    setIsFirstToolInGroup,
    streamingState,
  ]);

  // Push completed tools to history as they finish
  useEffect(() => {
    const toolsToPush: TrackedToolCall[] = [];
    for (let i = 0; i < toolCalls.length; i++) {
      const tc = toolCalls[i];
      if (pushedToolCallIdsRef.current.has(tc.request.callId)) continue;

      if (
        tc.status === 'success' ||
        tc.status === 'error' ||
        tc.status === 'cancelled'
      ) {
        // TODO(#22883): This lookahead logic is a tactical UI fix to prevent parallel agents
        // from tearing visually when they finish at slightly different times.
        // Architecturally, `useGeminiStream` should not be responsible for stitching
        // together semantic batches using timing/refs. `packages/core` should be
        // refactored to emit structured `ToolBatch` or `Turn` objects, and this layer
        // should simply render those semantic boundaries.
        // If this is an agent tool, look ahead to ensure all subsequent
        // contiguous agents in the same batch are also finished before pushing.
        const isAgent = tc.tool?.kind === Kind.Agent;
        if (isAgent) {
          let contigAgentsComplete = true;
          for (let j = i + 1; j < toolCalls.length; j++) {
            const nextTc = toolCalls[j];
            if (nextTc.tool?.kind === Kind.Agent) {
              if (
                nextTc.status !== 'success' &&
                nextTc.status !== 'error' &&
                nextTc.status !== 'cancelled'
              ) {
                contigAgentsComplete = false;
                break;
              }
            } else {
              // End of the contiguous agent block
              break;
            }
          }

          if (!contigAgentsComplete) {
            // Wait for the entire contiguous block of agents to finish
            break;
          }
        }

        toolsToPush.push(tc);
      } else {
        // Stop at first non-terminal tool to preserve order
        break;
      }
    }

    if (toolsToPush.length > 0) {
      const newPushed = new Set(pushedToolCallIdsRef.current);
      const isFirstInThisPush = isFirstToolInGroupRef.current;
      const isCompactModeEnabled =
        settings.merged.ui?.compactToolOutput === true;

      const groups: TrackedToolCall[][] = [];
      let currentGroup: TrackedToolCall[] = [];

      for (const tc of toolsToPush) {
        newPushed.add(tc.request.callId);

        if (tc.tool?.kind === Kind.Agent) {
          currentGroup.push(tc);
        } else {
          if (currentGroup.length > 0) {
            groups.push(currentGroup);
            currentGroup = [];
          }
          groups.push([tc]);
        }
      }
      if (currentGroup.length > 0) {
        groups.push(currentGroup);
      }

      for (let i = 0; i < groups.length; i++) {
        const group = groups[i];
        const isFirstInBatch = i === 0 && isFirstInThisPush;
        const lastTcInGroup = group[group.length - 1];
        const tcIndexInBatch = toolCalls.indexOf(lastTcInGroup);
        const isLastInBatch = tcIndexInBatch === toolCalls.length - 1;

        const nextTcInBatch =
          tcIndexInBatch < toolCalls.length - 1
            ? toolCalls[tcIndexInBatch + 1]
            : null;
        const prevTcInBatch =
          toolCalls.indexOf(group[0]) > 0
            ? toolCalls[toolCalls.indexOf(group[0]) - 1]
            : null;

        const historyItem = mapTrackedToolCallsToDisplay(group, {
          ...getToolGroupBorderAppearance(
            { type: 'tool_group', tools: toolCalls },
            activeShellPtyId,
            !!isShellFocused,
            [],
            backgroundTasks,
          ),
        });

        // Determine if this group starts with a compact tool
        const currentIsCompact =
          historyItem.tools.length === 1 &&
          isCompactTool(historyItem.tools[0], isCompactModeEnabled);

        let nextIsCompact = false;
        if (nextTcInBatch) {
          const nextHistoryItem = mapTrackedToolCallsToDisplay(nextTcInBatch);
          nextIsCompact =
            nextHistoryItem.tools.length === 1 &&
            isCompactTool(nextHistoryItem.tools[0], isCompactModeEnabled);
        }

        let prevWasCompact = false;
        if (prevTcInBatch) {
          const prevHistoryItem = mapTrackedToolCallsToDisplay(prevTcInBatch);
          prevWasCompact =
            prevHistoryItem.tools.length === 1 &&
            isCompactTool(prevHistoryItem.tools[0], isCompactModeEnabled);
        }

        historyItem.borderTop =
          isFirstInBatch || (!currentIsCompact && prevWasCompact);
        historyItem.borderBottom = currentIsCompact
          ? isLastInBatch && !nextIsCompact
          : isLastInBatch || nextIsCompact;

        addItem(historyItem);
      }

      setPushedToolCallIds(newPushed);

      // If this batch ONLY contains topics, and we were the first in the group,
      // the NEXT batch is still effectively the first VISIBLE bordered tool in the group.
      if (
        isFirstToolInGroupRef.current &&
        toolsToPush.every((tc) => isTopicTool(tc.request.name))
      ) {
        // Keep it true!
      } else {
        setIsFirstToolInGroup(false);
      }
    }
  }, [
    toolCalls,
    pushedToolCallIdsRef,
    isFirstToolInGroupRef,
    setPushedToolCallIds,
    setIsFirstToolInGroup,
    addItem,
    activeShellPtyId,
    isShellFocused,
    backgroundTasks,
    settings.merged.ui?.compactToolOutput,
  ]);
  const pendingToolGroupItems = useMemo((): HistoryItemWithoutId[] => {
    const remainingTools = toolCalls.filter(
      (tc) => !pushedToolCallIds.has(tc.request.callId),
    );

    const items: HistoryItemWithoutId[] = [];

    const appearance = getToolGroupBorderAppearance(
      { type: 'tool_group', tools: toolCalls },
      activeShellPtyId,
      !!isShellFocused,
      [],
      backgroundTasks,
    );

    if (remainingTools.length > 0) {
      // Should we draw a top border? Yes if NO previous tools were drawn,
      // OR if ALL previously drawn tools were topics (which don't draw top borders).
      let needsTopBorder = pushedToolCallIds.size === 0;
      if (!needsTopBorder) {
        const allPushedWereTopics = toolCalls
          .filter((tc) => pushedToolCallIds.has(tc.request.callId))
          .every((tc) => isTopicTool(tc.request.name));
        if (allPushedWereTopics) {
          needsTopBorder = true;
        }
      }

      items.push(
        mapTrackedToolCallsToDisplay(remainingTools, {
          borderTop: needsTopBorder,
          borderBottom: false, // Stay open to connect with the slice below
          ...appearance,
        }),
      );
    }
    // Always show a bottom border slice if we have ANY tools in the batch
    // and we haven't finished pushing the whole batch to history yet.
    // Once all tools are terminal and pushed, the last history item handles the closing border.
    const allTerminal =
      toolCalls.length > 0 &&
      toolCalls.every(
        (tc) =>
          tc.status === 'success' ||
          tc.status === 'error' ||
          tc.status === 'cancelled',
      );

    const allPushed =
      toolCalls.length > 0 &&
      toolCalls.every((tc) => pushedToolCallIds.has(tc.request.callId));

    const isToolVisible = (tc: TrackedToolCall) => {
      // AskUser tools and Plan Mode write/edit are handled by this logic
      if (!isRenderedInHistory(buildToolVisibilityContext(tc))) {
        return false;
      }

      // ToolGroupMessage explicitly hides Confirming tools because they are
      // rendered in the interactive ToolConfirmationQueue instead.
      const displayStatus = mapCoreStatusToDisplayStatus(tc.status);
      if (displayStatus === ToolCallStatus.Confirming) {
        return false;
      }

      // ToolGroupMessage now shows all non-canceled tools, so they are visible
      // in pending and we need to draw the closing border for them.
      return true;
    };

    let lastVisibleIsCompact = false;
    const isCompactModeEnabled = settings.merged.ui?.compactToolOutput === true;
    for (let i = toolCalls.length - 1; i >= 0; i--) {
      if (isToolVisible(toolCalls[i])) {
        const mapped = mapTrackedToolCallsToDisplay(toolCalls[i]);
        lastVisibleIsCompact = mapped.tools[0]
          ? isCompactTool(mapped.tools[0], isCompactModeEnabled)
          : false;
        break;
      }
    }

    if (
      toolCalls.length > 0 &&
      !(allTerminal && allPushed) &&
      toolCalls.some(isToolVisible) &&
      !lastVisibleIsCompact
    ) {
      items.push({
        type: 'tool_group' as const,
        tools: [] as IndividualToolCallDisplay[],
        borderTop: false,
        borderBottom: true,
        ...appearance,
      });
    }

    return items;
  }, [
    toolCalls,
    pushedToolCallIds,
    activeShellPtyId,
    isShellFocused,
    backgroundTasks,
    settings.merged.ui?.compactToolOutput,
  ]);

  const lastQueryRef = useRef<PartListUnion | null>(null);
  const lastPromptIdRef = useRef<string | null>(null);
  const loopDetectedRef = useRef(false);
  const [
    loopDetectionConfirmationRequest,
    setLoopDetectionConfirmationRequest,
  ] = useState<{
    onComplete: (result: { userSelection: 'disable' | 'keep' }) => void;
  } | null>(null);

  const activePtyId =
    activeShellPtyId ?? activeBackgroundExecutionId ?? undefined;

  const prevActiveShellPtyIdRef = useRef<number | null>(null);
  useEffect(() => {
    if (
      turnCancelledRef.current &&
      prevActiveShellPtyIdRef.current !== null &&
      activeShellPtyId === null
    ) {
      addItem({ type: MessageType.INFO, text: 'Request cancelled.' });
      setIsResponding(false);
    }
    prevActiveShellPtyIdRef.current = activeShellPtyId;
  }, [activeShellPtyId, addItem, setIsResponding]);

  useEffect(() => {
    if (
      config.getApprovalMode() === ApprovalMode.YOLO &&
      streamingState === StreamingState.Idle
    ) {
      const lastUserMessageIndex = history.findLastIndex(
        (item: HistoryItem) => item.type === MessageType.USER,
      );

      const turnCount =
        lastUserMessageIndex === -1 ? 0 : history.length - lastUserMessageIndex;

      if (turnCount > 0) {
        logConversationFinishedEvent(
          config,
          new ConversationFinishedEvent(config.getApprovalMode(), turnCount),
        );
      }
    }
  }, [streamingState, config, history]);

  useEffect(() => {
    if (!isResponding) {
      setRetryStatus(null);
    }
  }, [isResponding]);

  const maybeAddSuppressedToolErrorNote = useCallback(
    (userMessageTimestamp?: number) => {
      if (!isLowErrorVerbosity) {
        return;
      }
      if (suppressedToolErrorCountRef.current === 0) {
        return;
      }
      if (suppressedToolErrorNoteShownRef.current) {
        return;
      }

      addItem(
        {
          type: MessageType.INFO,
          text: SUPPRESSED_TOOL_ERRORS_NOTE,
        },
        userMessageTimestamp,
      );
      suppressedToolErrorNoteShownRef.current = true;
    },
    [addItem, isLowErrorVerbosity],
  );

  const maybeAddLowVerbosityFailureNote = useCallback(
    (userMessageTimestamp?: number) => {
      if (!isLowErrorVerbosity || config.getDebugMode()) {
        return;
      }
      if (
        lowVerbosityFailureNoteShownRef.current ||
        suppressedToolErrorNoteShownRef.current
      ) {
        return;
      }

      addItem(
        {
          type: MessageType.INFO,
          text: LOW_VERBOSITY_FAILURE_NOTE,
        },
        userMessageTimestamp,
      );
      lowVerbosityFailureNoteShownRef.current = true;
    },
    [addItem, config, isLowErrorVerbosity],
  );

  const cancelOngoingRequest = useCallback(
    (clearBuffer: boolean = true) => {
      // If we are already cancelled, do nothing
      if (turnCancelledRef.current) {
        if (clearBuffer) {
          onCancelSubmit(false, true);
        }
        return;
      }

      const hasActiveTools = toolCalls.some(
        (tc) =>
          tc.status === CoreToolCallStatus.Executing ||
          tc.status === CoreToolCallStatus.Scheduled ||
          tc.status === CoreToolCallStatus.Validating,
      );

      // If we are not responding, not waiting for confirmation, and have no active tools,
      // there is nothing to abort.
      if (
        streamingState === StreamingState.Idle &&
        !isRespondingRef.current &&
        !hasActiveTools
      ) {
        // Even if we are "idle", if we are called with clearBuffer=true (Ctrl+C),
        // we still want to clear the buffer.
        if (clearBuffer) {
          onCancelSubmit(false, true);
        }
        return;
      }

      turnCancelledRef.current = true;
      setRetryStatus(null);

      // A full cancellation means no tools have produced a final result yet.
      // This determines if we show a generic "Request cancelled" message.
      const isFullCancellation = !toolCalls.some(
        (tc) => tc.status === 'success' || tc.status === 'error',
      );

      // Ensure we have an abort controller, creating one if it doesn't exist.
      if (!abortControllerRef.current) {
        abortControllerRef.current = new AbortController();
      }

      // The order is important here.
      // 1. Fire the signal to interrupt any active async operations.
      abortControllerRef.current.abort();
      // 2. Call the imperative cancel to clear the queue of pending tools.
      cancelAllToolCalls(abortControllerRef.current.signal);

      if (pendingHistoryItemRef.current) {
        // If it is a shell command, we update the status to Canceled and clear the output
        // to avoid artifacts, then add it to history immediately.
        if (
          pendingHistoryItemRef.current.type === 'tool_group' &&
          pendingHistoryItemRef.current.tools.some(
            (t) => t.name === SHELL_COMMAND_NAME,
          )
        ) {
          const toolGroup = pendingHistoryItemRef.current;
          const updatedTools = toolGroup.tools.map((tool) => {
            if (tool.name === SHELL_COMMAND_NAME) {
              return {
                ...tool,
                status: CoreToolCallStatus.Cancelled,
                resultDisplay: tool.resultDisplay,
              };
            }
            return tool;
          });
          const newToolGroup: HistoryItemToolGroup = {
            ...toolGroup,
            tools: updatedTools,
          };
          addItem(newToolGroup);
        } else {
          addItem(pendingHistoryItemRef.current);
        }
      }
      setPendingHistoryItem(null);

      // If it was a full cancellation, add the info message now.
      // Otherwise, we let handleCompletedTools figure out the next step,
      // which might involve sending partial results back to the model.
      if (isFullCancellation) {
        // If shell is active, we delay this message to ensure correct ordering
        // (Shell item first, then Info message).
        if (!activeShellPtyId) {
          addItem({
            type: MessageType.INFO,
            text: 'Request cancelled.',
          });
          setIsResponding(false);
        }
      }

      onCancelSubmit(false, clearBuffer);
      setShellInputFocused(false);
    },
    [
      streamingState,
      addItem,
      setPendingHistoryItem,
      onCancelSubmit,
      pendingHistoryItemRef,
      isRespondingRef,
      setShellInputFocused,
      cancelAllToolCalls,
      toolCalls,
      activeShellPtyId,
      setIsResponding,
    ],
  );

  useKeypress(
    (key) => {
      if (key.name === 'escape' && !isShellFocused) {
        cancelOngoingRequest(false);
        return true;
      }
      return false;
    },
    {
      isActive:
        streamingState === StreamingState.Responding ||
        streamingState === StreamingState.WaitingForConfirmation,
    },
  );

  const prepareQueryForGemini = useCallback(
    async (
      query: PartListUnion,
      userMessageTimestamp: number,
      abortSignal: AbortSignal,
      prompt_id: string,
    ): Promise<{
      queryToSend: PartListUnion | null;
      shouldProceed: boolean;
    }> => {
      if (turnCancelledRef.current) {
        return { queryToSend: null, shouldProceed: false };
      }
      if (typeof query === 'string' && query.trim().length === 0) {
        return { queryToSend: null, shouldProceed: false };
      }

      let localQueryToSendToGemini: PartListUnion | null = null;

      if (typeof query === 'string') {
        const trimmedQuery = query.trim();
        await logger?.logMessage(MessageSenderType.USER, trimmedQuery);

        if (!shellModeActive) {
          // Handle UI-only commands first
          const slashCommandResult = isSlashCommand(trimmedQuery)
            ? await handleSlashCommand(trimmedQuery)
            : false;

          if (slashCommandResult) {
            switch (slashCommandResult.type) {
              case 'schedule_tool': {
                const { toolName, toolArgs, postSubmitPrompt } =
                  slashCommandResult;
                const toolCallRequest: ToolCallRequestInfo = {
                  callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
                  name: toolName,
                  args: toolArgs,
                  isClientInitiated: true,
                  prompt_id,
                };
                await scheduleToolCalls([toolCallRequest], abortSignal);

                if (postSubmitPrompt) {
                  localQueryToSendToGemini = postSubmitPrompt;
                  return {
                    queryToSend: localQueryToSendToGemini,
                    shouldProceed: true,
                  };
                }

                return { queryToSend: null, shouldProceed: false };
              }
              case 'submit_prompt': {
                localQueryToSendToGemini = slashCommandResult.content;

                return {
                  queryToSend: localQueryToSendToGemini,
                  shouldProceed: true,
                };
              }
              case 'handled': {
                return { queryToSend: null, shouldProceed: false };
              }
              default: {
                const unreachable: never = slashCommandResult;
                throw new Error(
                  `Unhandled slash command result type: ${unreachable}`,
                );
              }
            }
          }
        }

        if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {
          return { queryToSend: null, shouldProceed: false };
        }

        // Handle @-commands (which might involve tool calls)
        if (isAtCommand(trimmedQuery)) {
          // Add user's turn before @ command processing for correct UI ordering.
          addItem(
            { type: MessageType.USER, text: trimmedQuery },
            userMessageTimestamp,
          );

          const atCommandResult = await handleAtCommand({
            query: trimmedQuery,
            config,
            addItem,
            onDebugMessage,
            messageId: userMessageTimestamp,
            signal: abortSignal,
            escapePastedAtSymbols: settings.merged.ui?.escapePastedAtSymbols,
          });
          if (atCommandResult.error) {
            onDebugMessage(atCommandResult.error);
            return { queryToSend: null, shouldProceed: false };
          }
          localQueryToSendToGemini = atCommandResult.processedQuery;
        } else {
          // Normal query for Gemini
          addItem(
            { type: MessageType.USER, text: trimmedQuery },
            userMessageTimestamp,
          );
          localQueryToSendToGemini = trimmedQuery;
        }
      } else {
        // It's a function response (PartListUnion that isn't a string)
        localQueryToSendToGemini = query;
      }

      if (localQueryToSendToGemini === null) {
        onDebugMessage(
          'Query processing resulted in null, not sending to Gemini.',
        );
        return { queryToSend: null, shouldProceed: false };
      }
      return { queryToSend: localQueryToSendToGemini, shouldProceed: true };
    },
    [
      config,
      addItem,
      onDebugMessage,
      handleShellCommand,
      handleSlashCommand,
      logger,
      shellModeActive,
      scheduleToolCalls,
      settings,
    ],
  );

  // --- Stream Event Handlers ---

  const handleContentEvent = useCallback(
    (
      eventValue: ContentEvent['value'],
      currentGeminiMessageBuffer: string,
      userMessageTimestamp: number,
    ): string => {
      setRetryStatus(null);
      if (turnCancelledRef.current) {
        // Prevents additional output after a user initiated cancel.
        return '';
      }
      let newGeminiMessageBuffer = currentGeminiMessageBuffer + eventValue;
      if (
        pendingHistoryItemRef.current?.type !== 'gemini' &&
        pendingHistoryItemRef.current?.type !== 'gemini_content'
      ) {
        // Flush any pending item before starting gemini content
        if (pendingHistoryItemRef.current) {
          addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        }
        setPendingHistoryItem({ type: 'gemini', text: '' });
        newGeminiMessageBuffer = eventValue;
      }
      // Split large messages for better rendering performance. Ideally,
      // we should maximize the amount of output sent to <Static />.
      const splitPoint = findLastSafeSplitPoint(newGeminiMessageBuffer);
      if (splitPoint === newGeminiMessageBuffer.length) {
        // Update the existing message with accumulated content
        setPendingHistoryItem((item) => ({
          // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
          type: item?.type as 'gemini' | 'gemini_content',
          text: newGeminiMessageBuffer,
        }));
      } else {
        // This indicates that we need to split up this Gemini Message.
        // Splitting a message is primarily a performance consideration. There is a
        // <Static> component at the root of App.tsx which takes care of rendering
        // content statically or dynamically. Everything but the last message is
        // treated as static in order to prevent re-rendering an entire message history
        // multiple times per-second (as streaming occurs). Prior to this change you'd
        // see heavy flickering of the terminal. This ensures that larger messages get
        // broken up so that there are more "statically" rendered.
        const beforeText = newGeminiMessageBuffer.substring(0, splitPoint);
        const afterText = newGeminiMessageBuffer.substring(splitPoint);
        if (beforeText.length > 0) {
          addItem(
            {
              // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
              type: pendingHistoryItemRef.current?.type as
                | 'gemini'
                | 'gemini_content',
              text: beforeText,
            },
            userMessageTimestamp,
          );
        }
        setPendingHistoryItem({ type: 'gemini_content', text: afterText });
        newGeminiMessageBuffer = afterText;
      }
      return newGeminiMessageBuffer;
    },
    [addItem, pendingHistoryItemRef, setPendingHistoryItem],
  );

  const handleThoughtEvent = useCallback(
    (eventValue: ThoughtSummary, _userMessageTimestamp: number) => {
      setThought(eventValue);

      if (getInlineThinkingMode(settings) === 'full') {
        addItem({
          type: 'thinking',
          thought: eventValue,
        } as HistoryItemThinking);
      }
    },
    [addItem, settings, setThought],
  );

  const handleUserCancelledEvent = useCallback(
    (userMessageTimestamp: number) => {
      if (turnCancelledRef.current) {
        return;
      }
      if (pendingHistoryItemRef.current) {
        if (pendingHistoryItemRef.current.type === 'tool_group') {
          const updatedTools = pendingHistoryItemRef.current.tools.map(
            (tool) =>
              tool.status === CoreToolCallStatus.Validating ||
              tool.status === CoreToolCallStatus.Scheduled ||
              tool.status === CoreToolCallStatus.AwaitingApproval ||
              tool.status === CoreToolCallStatus.Executing
                ? { ...tool, status: CoreToolCallStatus.Cancelled }
                : tool,
          );

          const pendingItem: HistoryItemToolGroup = {
            ...pendingHistoryItemRef.current,
            tools: updatedTools,
          };
          addItem(pendingItem, userMessageTimestamp);
        } else {
          addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        }
        setPendingHistoryItem(null);
      }
      addItem(
        { type: MessageType.INFO, text: 'User cancelled the request.' },
        userMessageTimestamp,
      );
      setIsResponding(false);
      setThought(null); // Reset thought when user cancels
    },
    [
      addItem,
      pendingHistoryItemRef,
      setPendingHistoryItem,
      setThought,
      setIsResponding,
    ],
  );

  const handleErrorEvent = useCallback(
    (eventValue: GeminiErrorEventValue, userMessageTimestamp: number) => {
      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
      maybeAddSuppressedToolErrorNote(userMessageTimestamp);
      addItem(
        {
          type: MessageType.ERROR,
          text: parseAndFormatApiError(
            eventValue.error,
            config.getContentGeneratorConfig()?.authType,
            undefined,
            config.getModel(),
            DEFAULT_GEMINI_FLASH_MODEL,
          ),
        },
        userMessageTimestamp,
      );
      maybeAddLowVerbosityFailureNote(userMessageTimestamp);
      setThought(null); // Reset thought when there's an error
    },
    [
      addItem,
      pendingHistoryItemRef,
      setPendingHistoryItem,
      config,
      setThought,
      maybeAddSuppressedToolErrorNote,
      maybeAddLowVerbosityFailureNote,
    ],
  );

  const handleCitationEvent = useCallback(
    (text: string, userMessageTimestamp: number) => {
      if (!showCitations(settings)) {
        return;
      }

      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
      addItem({ type: MessageType.INFO, text }, userMessageTimestamp);
    },
    [addItem, pendingHistoryItemRef, setPendingHistoryItem, settings],
  );

  const handleFinishedEvent = useCallback(
    (event: ServerGeminiFinishedEvent, userMessageTimestamp: number) => {
      const finishReason = event.value.reason;
      if (!finishReason) {
        return;
      }

      const finishReasonMessages: Partial<
        Record<FinishReason, string | undefined>
      > = {
        [FinishReason.FINISH_REASON_UNSPECIFIED]: undefined,
        [FinishReason.STOP]: undefined,
        [FinishReason.MAX_TOKENS]: 'Response truncated due to token limits.',
        [FinishReason.SAFETY]: 'Response stopped due to safety reasons.',
        [FinishReason.RECITATION]: 'Response stopped due to recitation policy.',
        [FinishReason.LANGUAGE]:
          'Response stopped due to unsupported language.',
        [FinishReason.BLOCKLIST]: 'Response stopped due to forbidden terms.',
        [FinishReason.PROHIBITED_CONTENT]:
          'Response stopped due to prohibited content.',
        [FinishReason.SPII]:
          'Response stopped due to sensitive personally identifiable information.',
        [FinishReason.OTHER]: 'Response stopped for other reasons.',
        [FinishReason.MALFORMED_FUNCTION_CALL]:
          'Response stopped due to malformed function call.',
        [FinishReason.IMAGE_SAFETY]:
          'Response stopped due to image safety violations.',
        [FinishReason.UNEXPECTED_TOOL_CALL]:
          'Response stopped due to unexpected tool call.',
        [FinishReason.IMAGE_PROHIBITED_CONTENT]:
          'Response stopped due to prohibited image content.',
        [FinishReason.NO_IMAGE]:
          'Response stopped because no image was generated.',
      };

      const message = finishReasonMessages[finishReason];
      if (message) {
        addItem(
          {
            type: 'info',
            text: `⚠️  ${message}`,
          },
          userMessageTimestamp,
        );
      }
    },
    [addItem],
  );

  const handleChatCompressionEvent = useCallback(
    (
      eventValue: ServerGeminiChatCompressedEvent['value'],
      userMessageTimestamp: number,
    ) => {
      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }

      const limit = tokenLimit(config.getModel());
      const originalPercentage = Math.round(
        ((eventValue?.originalTokenCount ?? 0) / limit) * 100,
      );
      const newPercentage = Math.round(
        ((eventValue?.newTokenCount ?? 0) / limit) * 100,
      );

      addItem(
        {
          type: MessageType.INFO,
          text: `Context compressed from ${originalPercentage}% to ${newPercentage}%.`,
          secondaryText: `Change threshold in /settings.`,
          color: theme.status.warning,
          marginBottom: 1,
        } as HistoryItemInfo,
        userMessageTimestamp,
      );
    },
    [addItem, pendingHistoryItemRef, setPendingHistoryItem, config],
  );

  const handleMaxSessionTurnsEvent = useCallback(
    () =>
      addItem({
        type: 'info',
        text:
          `The session has reached the maximum number of turns: ${config.getMaxSessionTurns()}. ` +
          `Please update this limit in your setting.json file.`,
      }),
    [addItem, config],
  );

  const handleContextWindowWillOverflowEvent = useCallback(
    (estimatedRequestTokenCount: number, remainingTokenCount: number) => {
      onCancelSubmit(true);

      const limit = tokenLimit(config.getModel());

      const isMoreThan25PercentUsed =
        limit > 0 && remainingTokenCount < limit * 0.75;

      let text = `Sending this message (${estimatedRequestTokenCount} tokens) might exceed the context window limit (${remainingTokenCount.toLocaleString()} tokens left).`;

      if (isMoreThan25PercentUsed) {
        text +=
          ' Please try reducing the size of your message or use the `/compress` command to compress the chat history.';
      }

      addItem({
        type: 'info',
        text,
      });
    },
    [addItem, onCancelSubmit, config],
  );

  const handleChatModelEvent = useCallback(
    (eventValue: string, userMessageTimestamp: number) => {
      if (!settings.merged.ui.showModelInfoInChat) {
        return;
      }
      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
      addItem(
        {
          type: 'model',
          model: eventValue,
        } as HistoryItemModel,
        userMessageTimestamp,
      );
    },
    [addItem, pendingHistoryItemRef, setPendingHistoryItem, settings],
  );

  const handleAgentExecutionStoppedEvent = useCallback(
    (
      reason: string,
      userMessageTimestamp: number,
      systemMessage?: string,
      contextCleared?: boolean,
    ) => {
      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
      addItem(
        {
          type: MessageType.INFO,
          text: `Agent execution stopped: ${systemMessage?.trim() || reason}`,
        },
        userMessageTimestamp,
      );
      maybeAddLowVerbosityFailureNote(userMessageTimestamp);
      if (contextCleared) {
        addItem(
          {
            type: MessageType.INFO,
            text: 'Conversation context has been cleared.',
          },
          userMessageTimestamp,
        );
      }
      setIsResponding(false);
    },
    [
      addItem,
      pendingHistoryItemRef,
      setPendingHistoryItem,
      setIsResponding,
      maybeAddLowVerbosityFailureNote,
    ],
  );

  const handleAgentExecutionBlockedEvent = useCallback(
    (
      reason: string,
      userMessageTimestamp: number,
      systemMessage?: string,
      contextCleared?: boolean,
    ) => {
      if (pendingHistoryItemRef.current) {
        addItem(pendingHistoryItemRef.current, userMessageTimestamp);
        setPendingHistoryItem(null);
      }
      addItem(
        {
          type: MessageType.WARNING,
          text: `Agent execution blocked: ${systemMessage?.trim() || reason}`,
        },
        userMessageTimestamp,
      );
      maybeAddLowVerbosityFailureNote(userMessageTimestamp);
      if (contextCleared) {
        addItem(
          {
            type: MessageType.INFO,
            text: 'Conversation context has been cleared.',
          },
          userMessageTimestamp,
        );
      }
    },
    [
      addItem,
      pendingHistoryItemRef,
      setPendingHistoryItem,
      maybeAddLowVerbosityFailureNote,
    ],
  );

  const processGeminiStreamEvents = useCallback(
    async (
      stream: AsyncIterable<GeminiEvent>,
      userMessageTimestamp: number,
      signal: AbortSignal,
    ): Promise<StreamProcessingStatus> => {
      let geminiMessageBuffer = '';
      const toolCallRequests: ToolCallRequestInfo[] = [];
      for await (const event of stream) {
        if (
          event.type !== ServerGeminiEventType.Thought &&
          thoughtRef.current !== null
        ) {
          setThought(null);
        }

        switch (event.type) {
          case ServerGeminiEventType.Thought:
            setLastGeminiActivityTime(Date.now());
            handleThoughtEvent(event.value, userMessageTimestamp);
            break;
          case ServerGeminiEventType.Content:
            setLastGeminiActivityTime(Date.now());
            geminiMessageBuffer = handleContentEvent(
              event.value,
              geminiMessageBuffer,
              userMessageTimestamp,
            );
            break;
          case ServerGeminiEventType.ToolCallRequest:
            toolCallRequests.push(event.value);
            break;
          case ServerGeminiEventType.UserCancelled:
            handleUserCancelledEvent(userMessageTimestamp);
            break;
          case ServerGeminiEventType.Error:
            handleErrorEvent(event.value, userMessageTimestamp);
            break;
          case ServerGeminiEventType.AgentExecutionStopped:
            handleAgentExecutionStoppedEvent(
              event.value.reason,
              userMessageTimestamp,
              event.value.systemMessage,
              event.value.contextCleared,
            );
            break;
          case ServerGeminiEventType.AgentExecutionBlocked:
            handleAgentExecutionBlockedEvent(
              event.value.reason,
              userMessageTimestamp,
              event.value.systemMessage,
              event.value.contextCleared,
            );
            break;
          case ServerGeminiEventType.ChatCompressed:
            handleChatCompressionEvent(event.value, userMessageTimestamp);
            break;
          case ServerGeminiEventType.ToolCallConfirmation:
          case ServerGeminiEventType.ToolCallResponse:
            // do nothing
            break;
          case ServerGeminiEventType.MaxSessionTurns:
            handleMaxSessionTurnsEvent();
            break;
          case ServerGeminiEventType.ContextWindowWillOverflow:
            handleContextWindowWillOverflowEvent(
              event.value.estimatedRequestTokenCount,
              event.value.remainingTokenCount,
            );
            break;
          case ServerGeminiEventType.Finished:
            handleFinishedEvent(event, userMessageTimestamp);
            break;
          case ServerGeminiEventType.Citation:
            handleCitationEvent(event.value, userMessageTimestamp);
            break;
          case ServerGeminiEventType.ModelInfo:
            handleChatModelEvent(event.value, userMessageTimestamp);
            break;
          case ServerGeminiEventType.LoopDetected:
            // handle later because we want to move pending history to history
            // before we add loop detected message to history
            loopDetectedRef.current = true;
            break;
          case ServerGeminiEventType.Retry:
          case ServerGeminiEventType.InvalidStream:
            // Will add the missing logic later
            break;
          default: {
            // enforces exhaustive switch-case
            const unreachable: never = event;
            return unreachable;
          }
        }
      }
      if (toolCallRequests.length > 0) {
        if (pendingHistoryItemRef.current) {
          addItem(pendingHistoryItemRef.current, userMessageTimestamp);
          setPendingHistoryItem(null);
        }
        await scheduleToolCalls(toolCallRequests, signal);
      }
      return StreamProcessingStatus.Completed;
    },
    [
      handleContentEvent,
      handleThoughtEvent,
      thoughtRef,
      handleUserCancelledEvent,
      handleErrorEvent,
      scheduleToolCalls,
      handleChatCompressionEvent,
      handleFinishedEvent,
      handleMaxSessionTurnsEvent,
      handleContextWindowWillOverflowEvent,
      handleCitationEvent,
      handleChatModelEvent,
      handleAgentExecutionStoppedEvent,
      handleAgentExecutionBlockedEvent,
      addItem,
      pendingHistoryItemRef,
      setPendingHistoryItem,
      setThought,
    ],
  );
  const submitQuery = useCallback(
    async (
      query: PartListUnion,
      options?: { isContinuation: boolean },
      prompt_id?: string,
    ) =>
      runInDevTraceSpan(
        {
          operation: options?.isContinuation
            ? GeminiCliOperation.SystemPrompt
            : GeminiCliOperation.UserPrompt,
          sessionId: config.getSessionId(),
        },
        async ({ metadata: spanMetadata }) => {
          spanMetadata.input = query;

          if (
            (isRespondingRef.current ||
              streamingState === StreamingState.Responding ||
              streamingState === StreamingState.WaitingForConfirmation) &&
            !options?.isContinuation
          )
            return;
          const queryId = `${Date.now()}-${Math.random()}`;
          activeQueryIdRef.current = queryId;

          const userMessageTimestamp = Date.now();

          // Reset quota error flag when starting a new query (not a continuation)
          if (!options?.isContinuation) {
            setModelSwitchedFromQuotaError(false);
            config.setQuotaErrorOccurred(false);
            config.resetBillingTurnState(
              settings.merged.billing?.overageStrategy,
            );
            suppressedToolErrorCountRef.current = 0;
            suppressedToolErrorNoteShownRef.current = false;
            lowVerbosityFailureNoteShownRef.current = false;
          }

          abortControllerRef.current = new AbortController();
          const abortSignal = abortControllerRef.current.signal;
          turnCancelledRef.current = false;

          if (!prompt_id) {
            prompt_id = config.getSessionId() + '########' + getPromptCount();
          }
          return promptIdContext.run(prompt_id, async () => {
            const { queryToSend, shouldProceed } = await prepareQueryForGemini(
              query,
              userMessageTimestamp,
              abortSignal,
              prompt_id!,
            );

            if (!shouldProceed || queryToSend === null) {
              return;
            }

            if (!options?.isContinuation) {
              if (typeof queryToSend === 'string') {
                // logging the text prompts only for now
                const promptText = queryToSend;
                logUserPrompt(
                  config,
                  new UserPromptEvent(
                    promptText.length,
                    prompt_id!,
                    config.getContentGeneratorConfig()?.authType,
                    promptText,
                  ),
                );
              }
              startNewPrompt();
              setThought(null); // Reset thought when starting a new prompt
            }

            setIsResponding(true);
            setInitError(null);

            // Store query and prompt_id for potential retry on loop detection
            lastQueryRef.current = queryToSend;
            lastPromptIdRef.current = prompt_id!;

            try {
              const stream = geminiClient.sendMessageStream(
                queryToSend,
                abortSignal,
                prompt_id!,
                undefined,
                query,
              );
              const processingStatus = await processGeminiStreamEvents(
                stream,
                userMessageTimestamp,
                abortSignal,
              );

              if (processingStatus === StreamProcessingStatus.UserCancelled) {
                return;
              }

              if (pendingHistoryItemRef.current) {
                addItem(pendingHistoryItemRef.current, userMessageTimestamp);
                setPendingHistoryItem(null);
              }
              if (loopDetectedRef.current) {
                loopDetectedRef.current = false;
                // Show the confirmation dialog to choose whether to disable loop detection
                setLoopDetectionConfirmationRequest({
                  onComplete: async (result: {
                    userSelection: 'disable' | 'keep';
                  }) => {
                    setLoopDetectionConfirmationRequest(null);

                    if (result.userSelection === 'disable') {
                      config
                        .getGeminiClient()
                        .getLoopDetectionService()
                        .disableForSession();
                      addItem({
                        type: 'info',
                        text: `Loop detection has been disabled for this session. Retrying request...`,
                      });

                      if (lastQueryRef.current && lastPromptIdRef.current) {
                        await submitQuery(
                          lastQueryRef.current,
                          { isContinuation: true },
                          lastPromptIdRef.current,
                        );
                      }
                    } else {
                      addItem({
                        type: 'info',
                        text: `A potential loop was detected. This can happen due to repetitive tool calls or other model behavior. The request has been halted.`,
                      });
                    }
                  },
                });
              }
            } catch (error: unknown) {
              spanMetadata.error = error;
              if (error instanceof UnauthorizedError) {
                onAuthError('Session expired or is unauthorized.');
              } else if (
                // Suppress ValidationRequiredError if it was marked as handled (e.g. user clicked change_auth or cancelled)
                error instanceof ValidationRequiredError &&
                error.userHandled
              ) {
                // Error was handled by validation dialog, don't display again
              } else if (!isNodeError(error) || error.name !== 'AbortError') {
                maybeAddSuppressedToolErrorNote(userMessageTimestamp);
                addItem(
                  {
                    type: MessageType.ERROR,
                    text: parseAndFormatApiError(
                      getErrorMessage(error) || 'Unknown error',
                      config.getContentGeneratorConfig()?.authType,
                      undefined,
                      config.getModel(),
                      DEFAULT_GEMINI_FLASH_MODEL,
                    ),
                  },
                  userMessageTimestamp,
                );
                maybeAddLowVerbosityFailureNote(userMessageTimestamp);
              }
            } finally {
              if (activeQueryIdRef.current === queryId) {
                setIsResponding(false);
              }
            }
          });
        },
      ),
    [
      streamingState,
      setModelSwitchedFromQuotaError,
      prepareQueryForGemini,
      processGeminiStreamEvents,
      pendingHistoryItemRef,
      addItem,
      setPendingHistoryItem,
      setInitError,
      geminiClient,
      onAuthError,
      config,
      startNewPrompt,
      getPromptCount,
      setThought,
      maybeAddSuppressedToolErrorNote,
      maybeAddLowVerbosityFailureNote,
      isRespondingRef,
      settings.merged.billing?.overageStrategy,
      setIsResponding,
    ],
  );

  const handleApprovalModeChange = useCallback(
    async (newApprovalMode: ApprovalMode) => {
      if (
        previousApprovalModeRef.current === ApprovalMode.PLAN &&
        newApprovalMode !== ApprovalMode.PLAN &&
        streamingState === StreamingState.Idle
      ) {
        if (geminiClient) {
          try {
            await geminiClient.addHistory({
              role: 'user',
              parts: [
                {
                  text: getPlanModeExitMessage(newApprovalMode, true),
                },
              ],
            });
          } catch (error) {
            onDebugMessage(
              `Failed to notify model of Plan Mode exit: ${getErrorMessage(error)}`,
            );
            addItem({
              type: MessageType.ERROR,
              text: 'Failed to update the model about exiting Plan Mode. The model might be out of sync. Please consider restarting the session if you see unexpected behavior.',
            });
          }
        }
      }
      previousApprovalModeRef.current = newApprovalMode;

      // Auto-approve pending tool calls when switching to auto-approval modes
      if (
        newApprovalMode === ApprovalMode.YOLO ||
        newApprovalMode === ApprovalMode.AUTO_EDIT
      ) {
        let awaitingApprovalCalls = toolCalls.filter(
          (call): call is TrackedWaitingToolCall =>
            call.status === 'awaiting_approval' && !call.request.forcedAsk,
        );

        // For AUTO_EDIT mode, only approve edit tools (replace, write_file)
        if (newApprovalMode === ApprovalMode.AUTO_EDIT) {
          awaitingApprovalCalls = awaitingApprovalCalls.filter((call) =>
            EDIT_TOOL_NAMES.has(call.request.name),
          );
        }

        // Process pending tool calls sequentially to reduce UI chaos
        for (const call of awaitingApprovalCalls) {
          if (call.correlationId) {
            try {
              await config.getMessageBus().publish({
                type: MessageBusType.TOOL_CONFIRMATION_RESPONSE,
                correlationId: call.correlationId,
                confirmed: true,
                requiresUserConfirmation: false,
                outcome: ToolConfirmationOutcome.ProceedOnce,
              });
            } catch (error) {
              debugLogger.warn(
                `Failed to auto-approve tool call ${call.request.callId}:`,
                error,
              );
            }
          }
        }
      }
    },
    [config, toolCalls, geminiClient, streamingState, addItem, onDebugMessage],
  );

  const handleCompletedTools = useCallback(
    async (completedToolCallsFromScheduler: TrackedToolCall[]) => {
      const completedAndReadyToSubmitTools =
        completedToolCallsFromScheduler.filter(
          (
            tc: TrackedToolCall,
          ): tc is TrackedCompletedToolCall | TrackedCancelledToolCall => {
            const isTerminalState =
              tc.status === 'success' ||
              tc.status === 'error' ||
              tc.status === 'cancelled';

            if (isTerminalState) {
              const completedOrCancelledCall = tc as
                | TrackedCompletedToolCall
                | TrackedCancelledToolCall;
              return (
                completedOrCancelledCall.response?.responseParts !== undefined
              );
            }
            return false;
          },
        );

      // Finalize any client-initiated tools as soon as they are done.
      const clientTools = completedAndReadyToSubmitTools.filter(
        (t) => t.request.isClientInitiated,
      );
      if (clientTools.length > 0) {
        markToolsAsSubmitted(clientTools.map((t) => t.request.callId));

        if (geminiClient) {
          for (const tool of clientTools) {
            // Only manually record skill activations in the chat history.
            // Other client-initiated tools (like save_memory) update the system
            // prompt/context and don't strictly need to be in the history.
            if (tool.request.name !== ACTIVATE_SKILL_TOOL_NAME) {
              continue;
            }

            // Add both the call (model turn) and the result (user turn) to history.
            // Client-initiated calls are essentially "synthetic" turns that let
            // subsequent model calls understand what just happened in the UI.
            await geminiClient.addHistory({
              role: 'model',
              parts: [
                {
                  functionCall: {
                    name: tool.request.name,
                    args: tool.request.args,
                  },
                },
              ],
            });
            await geminiClient.addHistory({
              role: 'user',
              parts: tool.response.responseParts,
            });
          }
        }
      }

      // Identify new, successful save_memory calls that we haven't processed yet.
      const newSuccessfulMemorySaves = completedAndReadyToSubmitTools.filter(
        (t) =>
          t.request.name === 'save_memory' &&
          t.status === 'success' &&
          !processedMemoryToolsRef.current.has(t.request.callId),
      );

      for (const toolCall of completedAndReadyToSubmitTools) {
        const backgroundedTool = getBackgroundedToolInfo(toolCall);
        if (backgroundedTool) {
          registerBackgroundTask(
            backgroundedTool.pid,
            backgroundedTool.command,
            backgroundedTool.initialOutput,
          );
        }
      }

      if (newSuccessfulMemorySaves.length > 0) {
        // Perform the refresh only if there are new ones.
        void performMemoryRefresh();
        // Mark them as processed so we don't do this again on the next render.
        newSuccessfulMemorySaves.forEach((t) =>
          processedMemoryToolsRef.current.add(t.request.callId),
        );
      }

      const geminiTools = completedAndReadyToSubmitTools.filter(
        (t) => !t.request.isClientInitiated,
      );

      if (isLowErrorVerbosity) {
        // Low-mode suppression applies only to model-initiated tool failures.
        suppressedToolErrorCountRef.current += geminiTools.filter(
          (tc) => tc.status === CoreToolCallStatus.Error,
        ).length;
      }

      if (geminiTools.length === 0) {
        return;
      }

      // Check if any tool requested to stop execution immediately
      const stopExecutionTool = geminiTools.find(
        (tc) => tc.response.errorType === ToolErrorType.STOP_EXECUTION,
      );

      if (stopExecutionTool && stopExecutionTool.response.error) {
        maybeAddSuppressedToolErrorNote();
        addItem({
          type: MessageType.INFO,
          text: `Agent execution stopped: ${stopExecutionTool.response.error.message}`,
        });
        maybeAddLowVerbosityFailureNote();
        setIsResponding(false);

        const callIdsToMarkAsSubmitted = geminiTools.map(
          (toolCall) => toolCall.request.callId,
        );
        markToolsAsSubmitted(callIdsToMarkAsSubmitted);
        return;
      }

      // If all the tools were cancelled, don't submit a response to Gemini.
      // Note: we ignore the topic tool because the user doesn't have a chance to decline it.
      const declinableTools = geminiTools.filter(
        (tc) => !isTopicTool(tc.request.name),
      );
      const allDeclinableToolsCancelled =
        declinableTools.length > 0 &&
        declinableTools.every(
          (tc) => tc.status === CoreToolCallStatus.Cancelled,
        );
      const allToolsCancelled =
        geminiTools.length > 0 &&
        geminiTools.every((tc) => tc.status === CoreToolCallStatus.Cancelled);

      if (allDeclinableToolsCancelled || allToolsCancelled) {
        // If the turn was cancelled via the imperative escape key flow,
        // the cancellation message is added there. We check the ref to avoid duplication.
        if (!turnCancelledRef.current) {
          addItem({
            type: MessageType.INFO,
            text: 'Request cancelled.',
          });
        }
        setIsResponding(false);

        if (geminiClient) {
          // We need to manually add the function responses to the history
          // so the model knows the tools were cancelled.
          const combinedParts = geminiTools.flatMap(
            (toolCall) => toolCall.response.responseParts,
          );
          // eslint-disable-next-line @typescript-eslint/no-floating-promises
          geminiClient.addHistory({
            role: 'user',
            parts: combinedParts,
          });
        }

        const callIdsToMarkAsSubmitted = geminiTools.map(
          (toolCall) => toolCall.request.callId,
        );
        markToolsAsSubmitted(callIdsToMarkAsSubmitted);
        return;
      }

      const responsesToSend: Part[] = geminiTools.flatMap(
        (toolCall) => toolCall.response.responseParts,
      );

      if (consumeUserHint) {
        const userHint = consumeUserHint();
        if (userHint && userHint.trim().length > 0) {
          const hintText = userHint.trim();
          responsesToSend.unshift({
            text: buildUserSteeringHintPrompt(hintText),
          });
        }
      }

      const callIdsToMarkAsSubmitted = geminiTools.map(
        (toolCall) => toolCall.request.callId,
      );

      const prompt_ids = geminiTools.map(
        (toolCall) => toolCall.request.prompt_id,
      );

      markToolsAsSubmitted(callIdsToMarkAsSubmitted);

      // Don't continue if model was switched due to quota error
      if (modelSwitchedFromQuotaError) {
        return;
      }

      // eslint-disable-next-line @typescript-eslint/no-floating-promises
      submitQuery(
        responsesToSend,
        {
          isContinuation: true,
        },
        prompt_ids[0],
      );
    },
    [
      submitQuery,
      markToolsAsSubmitted,
      geminiClient,
      performMemoryRefresh,
      modelSwitchedFromQuotaError,
      addItem,
      registerBackgroundTask,
      consumeUserHint,
      isLowErrorVerbosity,
      maybeAddSuppressedToolErrorNote,
      maybeAddLowVerbosityFailureNote,
      setIsResponding,
    ],
  );

  const pendingHistoryItems = useMemo(
    () =>
      [pendingHistoryItem, ...pendingToolGroupItems].filter(
        (i): i is HistoryItemWithoutId => i !== undefined && i !== null,
      ),
    [pendingHistoryItem, pendingToolGroupItems],
  );

  useEffect(() => {
    const saveRestorableToolCalls = async () => {
      if (!config.getCheckpointingEnabled()) {
        return;
      }
      const restorableToolCalls = toolCalls.filter(
        (toolCall) =>
          EDIT_TOOL_NAMES.has(toolCall.request.name) &&
          toolCall.status === CoreToolCallStatus.AwaitingApproval,
      );

      if (restorableToolCalls.length > 0) {
        if (!gitService) {
          onDebugMessage(
            'Checkpointing is enabled but Git service is not available. Failed to create snapshot. Ensure Git is installed and working properly.',
          );
          return;
        }

        const { checkpointsToWrite, errors } = await processRestorableToolCalls<
          HistoryItem[]
        >(
          restorableToolCalls.map((call) => call.request),
          gitService,
          geminiClient,
          history,
        );

        if (errors.length > 0) {
          errors.forEach(onDebugMessage);
        }

        if (checkpointsToWrite.size > 0) {
          const checkpointDir = config.storage.getProjectTempCheckpointsDir();
          try {
            await fs.mkdir(checkpointDir, { recursive: true });
            for (const [fileName, content] of checkpointsToWrite) {
              const filePath = path.join(checkpointDir, fileName);
              await fs.writeFile(filePath, content);
            }
          } catch (error) {
            onDebugMessage(
              `Failed to write checkpoint file: ${getErrorMessage(error)}`,
            );
          }
        }
      }
    };
    // eslint-disable-next-line @typescript-eslint/no-floating-promises
    saveRestorableToolCalls();
  }, [toolCalls, config, onDebugMessage, gitService, history, geminiClient]);

  const lastOutputTime = Math.max(
    lastToolOutputTime,
    lastShellOutputTime,
    lastGeminiActivityTime,
  );

  return {
    streamingState,
    submitQuery,
    initError,
    pendingHistoryItems,
    thought,
    cancelOngoingRequest,
    pendingToolCalls: toolCalls,
    handleApprovalModeChange,
    activePtyId,
    loopDetectionConfirmationRequest,
    lastOutputTime,
    backgroundTaskCount,
    isBackgroundTaskVisible,
    toggleBackgroundTasks,
    backgroundCurrentExecution,
    backgroundTasks,
    dismissBackgroundTask,
    retryStatus,
  };
};
