// *****************************************************************************
// Copyright (C) 2024 EclipseSource GmbH.
//
// This program and the accompanying materials are made available under the
// terms of the Eclipse Public License v. 2.0 which is available at
// http://www.eclipse.org/legal/epl-2.0.
//
// This Source Code may also be made available under the following Secondary
// Licenses when the conditions for such availability set forth in the Eclipse
// Public License v. 2.0 are satisfied: GNU General Public License, version 2
// with the GNU Classpath Exception which is available at
// https://www.gnu.org/software/classpath/license.html.
//
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
import {
    ChangeSet, ChangeSetElement, ChatAgent, ChatChangeEvent, ChatHierarchyBranch,
    ChatModel, ChatRequestModel, ChatService, ChatSuggestion, EditableChatRequestModel,
    ChatRequestParser, ChatMode, ChatSession, MutableChatModel, ChatSessionSettings
} from '@theia/ai-chat';
import { ChatAgentService } from '@theia/ai-chat/lib/common/chat-agent-service';
import { ParsedChatRequest } from '@theia/ai-chat/lib/common/parsed-chat-request';
import {
    GenericCapabilitySelections, AIVariableResolutionRequest, ParsedCapability,
    FrontendLanguageModelRegistry, LanguageModel, ReasoningLevel, ReasoningSettings, ReasoningSupport,
    PREFERENCE_NAME_REASONING, ReasoningPreferenceEntry, ServerToolDescriptor
} from '@theia/ai-core';
import { mergeReasoningSettings } from '@theia/ai-core/lib/browser/frontend-language-model-service';
import { ChangeSetDecoratorService } from '@theia/ai-chat/lib/browser/change-set-decorator-service';
import { ImageContextVariable } from '@theia/ai-chat/lib/common/image-context-variable';
import { FrontendVariableService, AIActivationService } from '@theia/ai-core/lib/browser';
import { AISettingsService, PromptService } from '@theia/ai-core/lib/common';
import { CommandService, DisposableCollection, Emitter, InMemoryResources, MessageService, URI, nls, Disposable, ILogger } from '@theia/core';
import { CommonCommands, ContextMenuRenderer, HoverService, LabelProvider, Message, OpenerService, ReactWidget } from '@theia/core/lib/browser';
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { SelectComponent, SelectOption } from '@theia/core/lib/browser/widgets/select-component';
import { ContextKey, ContextKeyService } from '@theia/core/lib/browser/context-key-service';
import { KeybindingRegistry } from '@theia/core/lib/browser/keybinding';
import { Deferred } from '@theia/core/lib/common/promise-util';
import { inject, injectable, optional, postConstruct, named } from '@theia/core/shared/inversify';
import * as React from '@theia/core/shared/react';
import { IMouseEvent, Range } from '@theia/monaco-editor-core';
import { MonacoEditorProvider } from '@theia/monaco/lib/browser/monaco-editor-provider';
import { SimpleMonacoEditor } from '@theia/monaco/lib/browser/simple-monaco-editor';
import { ChangeSetActionRenderer, ChangeSetActionService } from './change-set-actions/change-set-action-service';
import { ChatInputAgentSuggestions } from './chat-input-agent-suggestions';
import { CHAT_VIEW_LANGUAGE_EXTENSION } from './chat-view-language-contribution';
import { ContextVariablePicker } from './context-variable-picker';
import { TASK_CONTEXT_VARIABLE } from '@theia/ai-chat/lib/browser/task-context-variable';
import { IModelDeltaDecoration } from '@theia/monaco-editor-core/esm/vs/editor/common/model';
import { EditorOption } from '@theia/monaco-editor-core/esm/vs/editor/common/config/editorOptions';
import { SuggestController } from '@theia/monaco-editor-core/esm/vs/editor/contrib/suggest/browser/suggestController';
import { ChatInputHistoryService, ChatInputNavigationState } from './chat-input-history';
import { ContextFileValidationService, FileValidationResult, FileValidationState } from '@theia/ai-chat/lib/browser/context-file-validation-service';
import { PendingImageRegistry } from '@theia/ai-chat/lib/browser/pending-image-registry';
import { ChatCapabilitiesService } from './chat-capabilities-service';
import { CapabilityChip, CapabilityChipsRow } from './chat-capabilities-panel';
import { ChatInputFocusService } from './chat-input-focus-service';
import { AvailableGenericCapabilities, GenericCapabilitiesService } from './generic-capabilities-service';
import { GenericCapabilitiesSection } from './generic-capabilities-section';
import { ServerToolsSection } from './generic-capabilities-tree';
import { PreferenceService } from '@theia/core/lib/common/preferences';
import {
    CHAT_VIEW_TOKEN_USAGE_ENABLED,
    CHAT_VIEW_TOKEN_USAGE_WARNING_ENABLED,
    CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE,
    CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE_DEFAULT
} from './chat-view-preferences';
import {
    buildBarTooltip,
    CHAT_CONTEXT_WINDOW_SIZE_FALLBACK,
    computeSessionTokenUsage,
    decideTokenUsageWarning,
    getLatestTokenUsage,
    getUsageColorClass
} from './chat-token-usage-indicator-util';
import { AI_CHAT_HOME, ChatCommands } from './chat-view-commands';

type Query = (query: string, mode?: string, capabilityOverrides?: Record<string, boolean>, genericCapabilitySelections?: GenericCapabilitySelections,
    serverToolSelections?: Record<string, string[]>) => Promise<void>;
type Unpin = () => void;
type Cancel = (requestModel: ChatRequestModel) => void;
type DeleteChangeSet = (requestModel: ChatRequestModel) => void;
type DeleteChangeSetElement = (requestModel: ChatRequestModel, index: number) => void;
type OpenContextElement = (request: AIVariableResolutionRequest) => unknown;

export const AIChatInputConfiguration = Symbol('AIChatInputConfiguration');
export interface AIChatInputConfiguration {
    showContext?: boolean;
    showPinnedAgent?: boolean;
    showChangeSet?: boolean;
    showSuggestions?: boolean;
    showCapabilities?: boolean;
    enablePromptHistory?: boolean;
}

@injectable()
export class AIChatInputWidget extends ReactWidget {
    public static ID = 'chat-input-widget';
    static readonly CONTEXT_MENU = ['chat-input-context-menu'];

    @inject(MonacoEditorProvider)
    protected readonly editorProvider: MonacoEditorProvider;

    @inject(InMemoryResources)
    protected readonly resources: InMemoryResources;

    @inject(ContextMenuRenderer)
    protected readonly contextMenuRenderer: ContextMenuRenderer;

    @inject(AIChatInputConfiguration) @optional()
    protected readonly configuration: AIChatInputConfiguration | undefined;

    @inject(FrontendVariableService)
    protected readonly variableService: FrontendVariableService;

    @inject(LabelProvider)
    protected readonly labelProvider: LabelProvider;

    @inject(ContextVariablePicker)
    protected readonly contextVariablePicker: ContextVariablePicker;

    @inject(ChangeSetActionService)
    protected readonly changeSetActionService: ChangeSetActionService;

    @inject(ChangeSetDecoratorService)
    protected readonly changeSetDecoratorService: ChangeSetDecoratorService;

    @inject(OpenerService)
    protected readonly openerService: OpenerService;

    @inject(ChatService)
    protected readonly chatService: ChatService;

    @inject(ChatAgentService)
    protected readonly chatAgentService: ChatAgentService;

    @inject(AIActivationService)
    protected readonly aiActivationService: AIActivationService;

    @inject(ChatInputHistoryService)
    protected readonly historyService: ChatInputHistoryService;

    @inject(ChatRequestParser)
    protected readonly chatRequestParser: ChatRequestParser;

    @inject(ContextFileValidationService) @optional()
    protected readonly validationService: ContextFileValidationService | undefined;

    @inject(ILogger) @named('ai-chat-ui:AIChatInputWidget')
    protected readonly logger: ILogger;

    @inject(PendingImageRegistry)
    protected readonly pendingImageRegistry: PendingImageRegistry;

    @inject(ChatCapabilitiesService)
    protected readonly capabilitiesService: ChatCapabilitiesService;

    @inject(GenericCapabilitiesService) @optional()
    protected readonly genericCapabilitiesService: GenericCapabilitiesService | undefined;

    protected fileValidationState = new Map<string, FileValidationResult>();

    @inject(ContextKeyService)
    protected readonly contextKeyService: ContextKeyService;

    @inject(KeybindingRegistry)
    protected readonly keybindingRegistry: KeybindingRegistry;

    @inject(HoverService)
    protected readonly hoverService: HoverService;

    @inject(ChatInputFocusService)
    protected readonly chatInputFocusService: ChatInputFocusService;

    @inject(AISettingsService)
    protected readonly aiSettingsService: AISettingsService;

    @inject(PromptService)
    protected readonly promptService: PromptService;

    @inject(FrontendLanguageModelRegistry)
    protected readonly languageModelRegistry: FrontendLanguageModelRegistry;

    @inject(PreferenceService) @optional()
    protected readonly preferenceService: PreferenceService | undefined;

    @inject(MessageService)
    protected readonly messageService: MessageService;

    @inject(CommandService)
    protected readonly commandService: CommandService;

    protected tokenUsageEnabled = false;
    /** Sessions we have already notified for the current warning cycle (re-armed when usage drops below the threshold). */
    protected readonly notifiedSessions = new Set<string>();

    protected navigationState: ChatInputNavigationState;

    protected editorRef: SimpleMonacoEditor | undefined = undefined;
    protected readonly editorReady = new Deferred<void>();

    get editor(): SimpleMonacoEditor | undefined {
        return this.editorRef;
    }

    get inputConfiguration(): AIChatInputConfiguration | undefined {
        return this.configuration;
    }

    getPreviousPrompt(currentInput: string): string | undefined {
        if (!this.navigationState) {
            return undefined;
        }
        return this.navigationState.getPreviousPrompt(currentInput);
    }

    getNextPrompt(): string | undefined {
        if (!this.navigationState) {
            return undefined;
        }
        return this.navigationState.getNextPrompt();
    }

    toggleCapabilities(): void {
        this.capabilitiesOpen = !this.capabilitiesOpen;
        this.update();
    }

    protected getKeybindingHint(commandId: string): string | undefined {
        const bindings = this.keybindingRegistry.getKeybindingsForCommand(commandId);
        if (bindings.length > 0) {
            const parts = this.keybindingRegistry.acceleratorFor(bindings[0], '+');
            return parts.join(' ');
        }
        return undefined;
    }

    protected getCapabilitiesKeybindingHint(): string | undefined {
        return this.getKeybindingHint('chat-input:toggle-capabilities');
    }

    protected getModeKeybindingHint(): string | undefined {
        return this.getKeybindingHint('chat-input:cycle-mode');
    }

    cycleMode(): void {
        if (!this.receivingAgent || !this.receivingAgent.modes || this.receivingAgent.modes.length <= 1) {
            return;
        }
        const currentIndex = this.receivingAgent.modes.findIndex(mode => mode.id === this.receivingAgent!.currentModeId);
        const nextIndex = currentIndex === -1 ? 1 : (currentIndex + 1) % this.receivingAgent.modes.length;
        const nextModeId = this.receivingAgent.modes[nextIndex].id;
        this.handleModeChange(nextModeId);
    }

    protected handleModeChange = async (mode: string): Promise<void> => {
        if (this.receivingAgent) {
            this.receivingAgent = { ...this.receivingAgent, currentModeId: mode };
            await this.updateCapabilitiesForAgent(this.receivingAgent.agentId, mode);
        }
    };

    /** Monotonic token for the latest async {@link updateReasoningSupport} run; older, out-of-order runs discard their results when this changes. */
    protected reasoningStateGeneration = 0;
    /** Monotonic token for the latest async {@link updateResolvedDefaultModel} run; older, out-of-order runs discard their results when this changes. */
    protected resolvedDefaultGeneration = 0;
    /** Reasoning capability of the model the receiving agent would currently use; undefined hides the selector. */
    protected currentReasoningSupport?: ReasoningSupport;
    /** Id (`provider/model`) of the model that backs {@link currentReasoningSupport}; used to resolve preference defaults. */
    protected currentLanguageModelId?: string;
    /** Context window (max input tokens) of the receiving agent's primary model; falls back to {@link CHAT_CONTEXT_WINDOW_SIZE_FALLBACK} when unknown. */
    protected currentMaxInputTokens?: number;
    /** Saved reasoning selection for the receiving agent (loaded from {@link AISettingsService}); kept in sync with the persisted value. */
    protected savedReasoning?: ReasoningSettings;
    /** Server tools declared by the receiving agent's primary model; undefined/empty hides the server tools category. */
    protected currentServerTools?: ServerToolDescriptor[];
    /** Vendor of the receiving agent's primary model; used to key server tool selections. */
    protected currentModelVendor?: string;

    protected handleReasoningChange = async (level: ReasoningLevel): Promise<void> => {
        const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel?.id);
        if (!session) {
            return;
        }
        const currentSettings = session.model.settings ?? {};
        const newSettings: ChatSessionSettings = {
            ...currentSettings,
            commonSettings: {
                ...currentSettings.commonSettings,
                reasoning: { level }
            }
        };
        (session.model as MutableChatModel).setSettings(newSettings);

        // Auto-persist the reasoning selection per-agent so it is restored on the next session
        // and the capabilities indicator does not light up for an unrelated configuration concern.
        if (this.receivingAgent) {
            try {
                await this.aiSettingsService.updateAgentSettings(this.receivingAgent.agentId, {
                    reasoning: { level }
                });
                this.savedReasoning = { level };
            } catch (error) {
                console.error('Failed to persist reasoning selection:', error);
            }
        }

        this.update();
    };

    /**
     * Resolves the reasoning level to display in the selector. Priority: session override →
     * persisted per-agent selection (from {@link AISettingsService}) →
     * `ai-features.reasoning.defaults` preference entry matching the current model/agent →
     * model's declared default → `'off'`.
     */
    protected getCurrentReasoningLevel(): ReasoningLevel | undefined {
        if (!this.currentReasoningSupport) {
            return undefined;
        }
        const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel?.id);
        const sessionLevel = session?.model.settings?.commonSettings?.reasoning?.level;
        if (sessionLevel) {
            return sessionLevel;
        }
        if (this.savedReasoning?.level) {
            return this.savedReasoning.level;
        }
        return this.resolvePreferenceReasoningLevel() ?? this.currentReasoningSupport.defaultLevel ?? 'off';
    }

    protected resolvePreferenceReasoningLevel(): ReasoningLevel | undefined {
        if (!this.preferenceService || !this.currentLanguageModelId) {
            return undefined;
        }
        const entries = this.preferenceService.get<ReasoningPreferenceEntry[]>(PREFERENCE_NAME_REASONING, []);
        const [providerId, modelId] = this.currentLanguageModelId.split('/');
        return mergeReasoningSettings(entries, modelId, providerId, this.receivingAgent?.agentId)?.reasoning?.level;
    }

    protected async updateReasoningSupport(agentId: string | undefined): Promise<void> {
        // Guard against out-of-order completion: if the model/session changes while the async lookups
        // below are in flight, a stale run must not overwrite the fields with outdated values.
        const generation = ++this.reasoningStateGeneration;
        let support: ReasoningSupport | undefined;
        let modelId: string | undefined;
        let maxInputTokens: number | undefined;
        let serverTools: ServerToolDescriptor[] | undefined;
        let vendor: string | undefined;
        // A per-session model override drives all model-dependent state (reasoning support, context
        // size, server tools, vendor) instead of the agent's configured default.
        const overrideId = this.getSessionModelOverride();
        const overrideModel = overrideId ? await this.languageModelRegistry.getReadyLanguageModel(overrideId) : undefined;
        if (overrideModel) {
            support = overrideModel.reasoningSupport;
            modelId = overrideModel.id;
            maxInputTokens = overrideModel.maxInputTokens;
            serverTools = overrideModel.serverTools;
            vendor = overrideModel.vendor;
        } else if (agentId) {
            const agent = this.chatAgentService.getAgent(agentId);
            if (agent) {
                for (const requirement of agent.languageModelRequirements ?? []) {
                    try {
                        const model = await this.languageModelRegistry.selectLanguageModel({ agent: agent.id, ...requirement });
                        if (!model) {
                            continue;
                        }
                        // Capture server tools / vendor from the primary (first resolved) model.
                        if (vendor === undefined) {
                            vendor = model.vendor;
                            serverTools = model.serverTools;
                        }
                        if (maxInputTokens === undefined && model.maxInputTokens !== undefined) {
                            maxInputTokens = model.maxInputTokens;
                        }
                        if (!support && model.reasoningSupport) {
                            support = model.reasoningSupport;
                            modelId = model.id;
                        }
                        if (support && maxInputTokens !== undefined) {
                            break;
                        }
                    } catch (error) {
                        console.warn('Failed to resolve language model for reasoning support:', error);
                    }
                }
            }
        }
        if (generation !== this.reasoningStateGeneration) {
            // A newer refresh started while we awaited; its results win.
            return;
        }
        if (support !== this.currentReasoningSupport
            || modelId !== this.currentLanguageModelId
            || maxInputTokens !== this.currentMaxInputTokens
            || vendor !== this.currentModelVendor
            || serverTools !== this.currentServerTools) {
            this.currentReasoningSupport = support;
            this.currentLanguageModelId = modelId;
            this.currentMaxInputTokens = maxInputTokens;
            this.currentServerTools = serverTools;
            this.currentModelVendor = vendor;
            this.update();
        }
        this.updateResolvedDefaultModel();
    }

    protected handleCapabilityChange = (fragmentId: string, enabled: boolean): void => {
        const defaultCapability = this.capabilityDefaults.find(c => c.fragmentId === fragmentId);
        const sessionOverrides = new Map(this.userCapabilityOverrides);

        if (enabled === defaultCapability?.defaultEnabled) {
            // User set it back to default, remove the override
            sessionOverrides.delete(fragmentId);
        } else {
            // User explicitly changed from default, add as override
            sessionOverrides.set(fragmentId, enabled);
        }

        this.userCapabilityOverrides = sessionOverrides;
        this.update();
    };

    protected handleGenericCapabilityChange = (type: keyof GenericCapabilitySelections, ids: string[]): void => {
        this.genericCapabilitySelections = {
            ...this.genericCapabilitySelections,
            [type]: ids
        };
        this.update();
    };

    protected handleResetGenericCapabilities = (): void => {
        const saved = this.savedGenericCapabilitySelections ?? {};
        this.genericCapabilitySelections = { ...saved };
        this.update();
    };

    protected handleServerToolChange = (ids: string[]): void => {
        if (!this.currentModelVendor) {
            return;
        }
        this.serverToolSelections = {
            ...this.serverToolSelections,
            [this.currentModelVendor]: ids
        };
        this.update();
    };

    protected async updateCapabilitiesForAgent(agentId: string, modeId?: string, preserveOverrides?: boolean): Promise<void> {
        const capabilities = await this.capabilitiesService.getCapabilitiesForAgent(agentId, modeId);
        this.capabilityDefaults = capabilities;
        if (!preserveOverrides) {
            // Load saved settings from preferences
            const agentSettings = await this.aiSettingsService.getAgentSettings(agentId);
            const savedOverrides = agentSettings?.capabilityOverrides;
            const savedGenericSelections = agentSettings?.genericCapabilitySelections;
            const savedServerToolSelections = agentSettings?.serverToolSelections;
            const savedReasoning = agentSettings?.reasoning;

            // Store saved state for comparison
            this.savedCapabilityOverrides = savedOverrides ? { ...savedOverrides } : undefined;
            this.savedGenericCapabilitySelections = savedGenericSelections ? { ...savedGenericSelections } : undefined;
            this.savedServerToolSelections = savedServerToolSelections ? { ...savedServerToolSelections } : undefined;
            this.savedReasoning = savedReasoning ? { ...savedReasoning } : undefined;

            // Initialize from saved settings, or empty if none
            this.userCapabilityOverrides = savedOverrides
                ? new Map(Object.entries(savedOverrides))
                : new Map<string, boolean>();
            this.genericCapabilitySelections = savedGenericSelections ?? {};
            this.serverToolSelections = savedServerToolSelections ? { ...savedServerToolSelections } : {};
            // Mirror the saved per-agent reasoning into the chat session so the selector reflects it
            // immediately on session/agent switch.
            this.applyReasoningToSession(savedReasoning);
        }

        // Update disabled generic capabilities (already used in agent prompt)
        this.disabledGenericCapabilities = await this.capabilitiesService.getUsedGenericCapabilitiesForAgent(agentId, modeId);

        this.update();
    }

    /** Updates the active chat session's `commonSettings.reasoning`; pass `undefined` to clear. */
    protected applyReasoningToSession(reasoning: ReasoningSettings | undefined): void {
        const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel?.id);
        if (!session) {
            return;
        }
        const currentSettings = session.model.settings ?? {};
        const currentCommon = currentSettings.commonSettings ?? {};
        if ((currentCommon.reasoning?.level ?? undefined) === (reasoning?.level ?? undefined)) {
            return; // no-op when already in sync
        }
        const newCommon: typeof currentCommon = { ...currentCommon };
        if (reasoning) {
            newCommon.reasoning = { ...reasoning };
        } else {
            delete newCommon.reasoning;
        }
        (session.model as MutableChatModel).setSettings({ ...currentSettings, commonSettings: newCommon });
    }

    /** Language models available for the per-session model selector; refreshed on registry changes. */
    protected availableModels: LanguageModel[] = [];
    /** Concrete model id the agent default currently resolves to (e.g. what `default/code` points at); falls back to the identifier. */
    protected resolvedDefaultLabel?: string;

    protected async loadAvailableModels(): Promise<void> {
        this.availableModels = await this.languageModelRegistry.getLanguageModels();
    }

    /**
     * Resolves the model a new session would use for this agent, honoring the per-agent override
     * configured in the AI configuration (via {@link AISettingsService}) and following aliases, so
     * the selector's "Default" reflects the effective, resolved target.
     */
    protected async updateResolvedDefaultModel(): Promise<void> {
        // Guard against out-of-order completion, mirroring updateReasoningSupport.
        const generation = ++this.resolvedDefaultGeneration;
        const agent = this.receivingAgent ? this.chatAgentService.getAgent(this.receivingAgent.agentId) : undefined;
        const requirement = agent?.languageModelRequirements?.[0];
        let label: string | undefined;
        if (agent && requirement) {
            const agentSettings = await this.aiSettingsService.getAgentSettings(agent.id);
            const effective = agentSettings?.languageModelRequirements?.find(r => r.purpose === requirement.purpose)?.identifier
                ?? requirement.identifier;
            const resolved = await this.languageModelRegistry.selectLanguageModel({ agent: agent.id, ...requirement });
            const resolvedId = resolved?.id;
            // Show "alias → resolved" when the effective default is an alias that resolves to a concrete model.
            label = resolvedId && effective && resolvedId !== effective ? `${effective} → ${resolvedId}` : (resolvedId ?? effective);
        }
        if (generation !== this.resolvedDefaultGeneration) {
            // A newer refresh started while we awaited; its results win.
            return;
        }
        if (label !== this.resolvedDefaultLabel) {
            this.resolvedDefaultLabel = label;
            this.update();
        }
    }

    /** The per-session model override id for the active session, if any. */
    protected getSessionModelOverride(): string | undefined {
        const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel?.id);
        return session?.model.settings?.commonSettings?.modelId;
    }

    /** Sets (or clears, with `undefined`) the per-session model override for the active session. */
    protected handleSessionModelChange = (modelId: string | undefined): void => {
        const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel?.id);
        if (!session) {
            return;
        }
        const currentSettings = session.model.settings ?? {};
        const currentCommon = currentSettings.commonSettings ?? {};
        if ((currentCommon.modelId ?? undefined) === (modelId ?? undefined)) {
            return;
        }
        const newCommon: typeof currentCommon = { ...currentCommon };
        if (modelId) {
            newCommon.modelId = modelId;
        } else {
            delete newCommon.modelId;
        }
        (session.model as MutableChatModel).setSettings({ ...currentSettings, commonSettings: newCommon });
        // The override changes the effective model, so refresh all model-dependent state.
        this.updateReasoningSupport(this.receivingAgent?.agentId);
        this.update();
    };

    /** Builds the props for the per-session model selector. */
    protected getModelSelectorProps(): ModelSelectorWidgetProps {
        const currentModelId = this.getSessionModelOverride();
        const defaultLabel = this.resolvedDefaultLabel
            ?? nls.localize('theia/ai/chat-ui/agentDefaultModel', 'agent default');
        return {
            models: this.availableModels,
            currentModelId,
            defaultLabel,
            onModelChange: this.handleSessionModelChange,
        };
    }

    protected async updateAvailableGenericCapabilities(): Promise<void> {
        if (!this.genericCapabilitiesService) {
            return;
        }

        const mcpFunctions = await this.genericCapabilitiesService.getAvailableMCPFunctions();

        this.availableGenericCapabilities = {
            skills: this.genericCapabilitiesService.getAvailableSkills(),
            mcpFunctions,
            functions: this.genericCapabilitiesService.getAvailableFunctions(),
            promptFragments: this.genericCapabilitiesService.getAvailablePromptFragments(),
            agentDelegation: this.genericCapabilitiesService.getAvailableAgents(this.receivingAgent?.agentId),
            variables: this.genericCapabilitiesService.getAvailableVariables()
        };

        this.update();
    }

    /**
     * Extracts capability overrides from the last request in the chat model.
     * Used to restore user's capability choices when switching sessions or on reload.
     */
    protected getLastCapabilityOverridesFromModel(chatModel: ChatModel): Map<string, boolean> {
        const requests = chatModel.getRequests();
        if (requests.length === 0) {
            return new Map();
        }
        const lastRequest = requests[requests.length - 1];
        const overrides = lastRequest.request.capabilityOverrides;
        if (!overrides) {
            return new Map();
        }
        return new Map(Object.entries(overrides));
    }

    /**
     * Extracts the mode ID from the last request in the chat model.
     * Used to restore the user's selected mode when switching sessions or on reload.
     */
    protected getLastModeIdFromModel(chatModel: ChatModel): string | undefined {
        const requests = chatModel.getRequests();
        if (requests.length === 0) {
            return undefined;
        }
        const lastRequest = requests[requests.length - 1];
        return lastRequest.request.modeId;
    }

    /**
     * Extracts generic capability selections from the last request in the chat model.
     * Used to restore user's selections when switching sessions or on reload.
     */
    protected getLastGenericCapabilitySelectionsFromModel(chatModel: ChatModel): GenericCapabilitySelections {
        const requests = chatModel.getRequests();
        if (requests.length === 0) {
            return {};
        }
        const lastRequest = requests[requests.length - 1];
        return lastRequest.request.genericCapabilitySelections ?? {};
    }

    /**
     * Extracts server tool selections from the last request in the chat model.
     * Used to restore the user's selections when switching sessions or on reload.
     */
    protected getLastServerToolSelectionsFromModel(chatModel: ChatModel): Record<string, string[]> {
        const requests = chatModel.getRequests();
        if (requests.length === 0) {
            return {};
        }
        const lastRequest = requests[requests.length - 1];
        return lastRequest.request.serverToolSelections ?? {};
    }

    /**
     * Builds the server tools section for the capabilities panel, or `undefined` if the current
     * model declares no server tools.
     */
    protected getServerToolsSection(): ServerToolsSection | undefined {
        const vendor = this.currentModelVendor;
        const tools = this.currentServerTools;
        if (!vendor || !tools || tools.length === 0) {
            return undefined;
        }
        return {
            providerName: this.getProviderDisplayName(vendor),
            vendor,
            tools,
            selectedIds: this.serverToolSelections[vendor] ?? [],
            onChange: this.handleServerToolChange
        };
    }

    protected getProviderDisplayName(vendor: string): string {
        return vendor.length > 0 ? vendor.charAt(0).toUpperCase() + vendor.slice(1) : vendor;
    }

    /**
     * Refreshes capabilities for the current receiving agent.
     * Called when prompt fragments change to ensure capabilities reflect the latest template.
     */
    protected async refreshCapabilities(): Promise<void> {
        if (this.receivingAgent) {
            await this.updateCapabilitiesForAgent(this.receivingAgent.agentId, this.receivingAgent.currentModeId, true);
        }
    }

    /**
     * Checks if current capability overrides differ from saved settings.
     */
    protected hasCapabilityChangesFromSaved(): boolean {
        const saved = this.savedCapabilityOverrides ?? {};
        const savedKeys = Object.keys(saved);
        const currentKeys = Array.from(this.userCapabilityOverrides.keys());

        if (savedKeys.length !== currentKeys.length) {
            return true;
        }

        return !currentKeys.every(key => saved[key] === this.userCapabilityOverrides.get(key));
    }

    /**
     * Compares two string arrays for equality (order-independent).
     */
    protected arraysEqualUnordered(a: string[], b: string[]): boolean {
        if (a.length !== b.length) {
            return false;
        }
        const setA = new Set(a);
        const setB = new Set(b);
        if (setA.size !== setB.size) {
            return false;
        }
        for (const item of setB) {
            if (!setA.has(item)) {
                return false;
            }
        }
        return true;
    }

    /** Returns true if any vendor has at least one selected server tool. */
    protected serverToolSelectionsHaveAny(selections: Record<string, string[]>): boolean {
        return Object.values(selections).some(ids => ids.length > 0);
    }

    /**
     * Checks if current generic capability selections differ from saved settings.
     */
    protected hasGenericCapabilityChangesFromSaved(): boolean {
        const saved = this.savedGenericCapabilitySelections ?? {};
        const current = this.genericCapabilitySelections;

        const types: (keyof GenericCapabilitySelections)[] = ['skills', 'mcpFunctions', 'functions', 'promptFragments', 'agentDelegation', 'variables'];
        for (const type of types) {
            const savedArray = saved[type] ?? [];
            const currentArray = current[type] ?? [];

            if (!this.arraysEqualUnordered(savedArray, currentArray)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Checks if current server tool selections differ from saved settings (across all vendors).
     */
    protected hasServerToolChangesFromSaved(): boolean {
        const saved = this.savedServerToolSelections ?? {};
        const current = this.serverToolSelections;
        const vendors = new Set([...Object.keys(saved), ...Object.keys(current)]);
        for (const vendor of vendors) {
            if (!this.arraysEqualUnordered(saved[vendor] ?? [], current[vendor] ?? [])) {
                return true;
            }
        }
        return false;
    }

    /**
     * Checks if there are any unsaved changes (capability overrides, generic selections, or server tools).
     * Reasoning is auto-persisted in {@link handleReasoningChange} and is intentionally excluded.
     */
    public hasAnyChangesFromSaved(): boolean {
        if (this.receivingAgent === undefined) {
            return false;
        }
        return this.hasCapabilityChangesFromSaved()
            || this.hasGenericCapabilityChangesFromSaved()
            || this.hasServerToolChangesFromSaved();
    }

    /**
     * Saves current capability selections to settings.
     * Reasoning is auto-persisted via {@link handleReasoningChange} and is not part of this flow.
     */
    public async saveCurrentSelectionsToSettings(): Promise<void> {
        if (!this.receivingAgent) {
            return;
        }

        const agentId = this.receivingAgent.agentId;

        // Convert userCapabilityOverrides Map to Record
        const capabilityOverrides: Record<string, boolean> = {};
        for (const [key, value] of this.userCapabilityOverrides) {
            capabilityOverrides[key] = value;
        }

        const hasServerToolSelections = this.serverToolSelectionsHaveAny(this.serverToolSelections);
        try {
            await this.aiSettingsService.updateAgentSettings(agentId, {
                capabilityOverrides: Object.keys(capabilityOverrides).length > 0 ? capabilityOverrides : undefined,
                genericCapabilitySelections: GenericCapabilitySelections.hasSelections(this.genericCapabilitySelections)
                    ? this.genericCapabilitySelections
                    : undefined,
                serverToolSelections: hasServerToolSelections ? this.serverToolSelections : undefined
            });

            // Update saved state to match current
            this.savedCapabilityOverrides = Object.keys(capabilityOverrides).length > 0 ? { ...capabilityOverrides } : undefined;
            this.savedGenericCapabilitySelections = GenericCapabilitySelections.hasSelections(this.genericCapabilitySelections)
                ? { ...this.genericCapabilitySelections }
                : undefined;
            this.savedServerToolSelections = hasServerToolSelections ? { ...this.serverToolSelections } : undefined;

            this.update();
        } catch (error) {
            console.error('Failed to save capability selections to settings:', error);
        }
    }

    protected chatInputFocusKey: ContextKey<boolean>;
    protected chatInputFirstLineKey: ContextKey<boolean>;
    protected chatInputLastLineKey: ContextKey<boolean>;
    protected chatInputReceivingAgentKey: ContextKey<string>;
    protected chatInputHasModesKey: ContextKey<boolean>;
    protected capabilitiesOpen = false;

    protected isEnabled = false;
    protected heightInLines = 12;

    protected updateReceivingAgentTimeout: number | undefined;
    protected forceCapabilitiesRefresh = false;
    protected receivingAgent: {
        agentId: string;
        modes: ChatMode[];
        currentModeId?: string;
    } | undefined;
    /**
     * Tracks the default enabled state for each capability.
     * This is initialized from the agent's capabilities and used to display the initial chip state.
     */
    protected capabilityDefaults: ParsedCapability[] = [];
    /**
     * Tracks user's generic capability selections from the dropdowns.
     */
    protected genericCapabilitySelections: GenericCapabilitySelections = {};
    /**
     * Tracks user's server tool selections, keyed by model vendor to keep selections provider-specific.
     */
    protected serverToolSelections: Record<string, string[]> = {};
    /**
     * Stores the saved server tool selections loaded from settings, for unsaved-change detection.
     */
    protected savedServerToolSelections: Record<string, string[]> | undefined;
    /**
     * Available generic capabilities from all sources.
     */
    protected availableGenericCapabilities: AvailableGenericCapabilities = {
        skills: [],
        mcpFunctions: [],
        functions: [],
        promptFragments: [],
        agentDelegation: [],
        variables: []
    };
    /**
     * Generic capabilities that are already used in the agent's prompt (should be disabled).
     */
    protected disabledGenericCapabilities: GenericCapabilitySelections = {};
    /**
     * Tracks user's explicit capability overrides for the current chat input session.
     * Only contains capabilities the user has explicitly changed from their defaults.
     * These overrides are not persisted but reset when the receiving agent changes or a new chat is started.
     * Or restored when an existing chat session can provide a previous capability override from the last request.
     */
    protected userCapabilityOverrides: Map<string, boolean> = new Map();
    /**
     * Stores the saved capability overrides loaded from settings.
     * Used to compare against current selections to detect unsaved changes.
     */
    protected savedCapabilityOverrides: Record<string, boolean> | undefined;
    /**
     * Stores the saved generic capability selections loaded from settings.
     * Used to compare against current selections to detect unsaved changes.
     */
    protected savedGenericCapabilitySelections: GenericCapabilitySelections | undefined;

    protected _branch?: ChatHierarchyBranch;
    set branch(branch: ChatHierarchyBranch | undefined) {
        if (this._branch !== branch) {
            this._branch = branch;
            this.update();
        }
    }

    protected _onQuery: Query;
    /** Suppresses capability updates while a query is in flight to prevent race conditions */
    protected queryInFlight = false;
    set onQuery(query: Query) {
        this._onQuery = async (prompt: string, mode?: string, capabilityOverrides?: Record<string, boolean>,
            genericCapabilitySelections?: GenericCapabilitySelections, serverToolSelections?: Record<string, string[]>) => {
            if (this.configuration?.enablePromptHistory !== false && prompt.trim()) {
                this.historyService.addToHistory(prompt);
                this.navigationState.stopNavigation();
            }
            this.queryInFlight = true;
            try {
                await query(prompt, mode, capabilityOverrides, genericCapabilitySelections, serverToolSelections);
            } finally {
                this.queryInFlight = false;
            }
        };
    }
    protected _onUnpin: Unpin;
    set onUnpin(unpin: Unpin) {
        this._onUnpin = unpin;
    }
    protected _onCancel: Cancel;
    set onCancel(cancel: Cancel) {
        this._onCancel = cancel;
    }
    protected _onDeleteChangeSet: DeleteChangeSet;
    set onDeleteChangeSet(deleteChangeSet: DeleteChangeSet) {
        this._onDeleteChangeSet = deleteChangeSet;
    }
    protected _onDeleteChangeSetElement: DeleteChangeSetElement;
    set onDeleteChangeSetElement(deleteChangeSetElement: DeleteChangeSetElement) {
        this._onDeleteChangeSetElement = deleteChangeSetElement;
    }

    protected _initialValue?: string;
    set initialValue(value: string | undefined) {
        this._initialValue = value;
    }

    protected onDisposeForChatModel = new DisposableCollection();
    protected _chatModel: ChatModel;

    /**
     * Disposables for pending image registrations, keyed by short ID.
     * These are cleared after the message is sent.
     */
    protected pendingImageDisposables = new Map<string, Disposable>();
    set chatModel(chatModel: ChatModel) {
        this.onDisposeForChatModel.dispose();
        this.onDisposeForChatModel = new DisposableCollection();
        // Register mapping from editor URI to model ID for hover provider lookups
        this.onDisposeForChatModel.push(
            this.pendingImageRegistry.registerEditorMapping(this.getResourceUri().toString(), chatModel.id)
        );

        // Reset capabilities panel state for each session switch
        this.capabilitiesOpen = false;
        // Force capabilities refresh on next agent update, even if the same agent is resolved
        this.forceCapabilitiesRefresh = true;

        // Restore capability overrides, generic selections and server tool selections from the last request in this session (if any)
        this.userCapabilityOverrides = this.getLastCapabilityOverridesFromModel(chatModel);
        this.genericCapabilitySelections = this.getLastGenericCapabilitySelectionsFromModel(chatModel);
        this.serverToolSelections = this.getLastServerToolSelectionsFromModel(chatModel);

        this.onDisposeForChatModel.push(chatModel.onDidChange(event => {
            if (event.kind === 'responseChanged') {
                this.evaluateTokenUsageWarning(chatModel);
            }
            if (event.kind === 'addVariable') {
                // Validate files added via any path (including LLM tool calls)
                // Get the current variables and validate any new file variables
                const variables = chatModel.context.getVariables();
                variables.forEach(variable => {
                    if (variable.variable.name === 'file' && variable.arg) {
                        const pathKey = variable.arg; // Use the original path as the key
                        // Revalidate the file each time someone (User or LLM) adds it to the context,
                        // as the state may change over time.
                        if (this.validationService) {
                            this.validationService.validateFile(pathKey).then(result => {
                                this.fileValidationState.set(pathKey, result);
                                this.update();
                            });
                        }
                    }
                });
                this.update();
            } else if (event.kind === 'removeVariable' || event.kind === 'addRequest' || event.kind === 'changeHierarchyBranch') {
                this.update();
            }
        }));
        this._chatModel = chatModel;
        // Evaluate the warning on attach. `notifiedSessions` lives on this widget
        // instance, so the warning fires at most once per (widget lifetime × session):
        // - Within the same widget, switching between sessions that have already been
        //   notified does not re-notify.
        // - Closing and reopening the chat view creates a fresh widget with an empty
        //   Set, so sessions still above the threshold will be warned about again the
        //   first time they are shown after reopen — once per session. Accepted as a
        //   rare corner case; promoting the state to the ChatSession would avoid it
        //   but isn't worth the coupling today.
        this.evaluateTokenUsageWarning(chatModel);
        this.scheduleUpdateReceivingAgent();
        this.update();
    }
    protected _pinnedAgent: ChatAgent | undefined;
    set pinnedAgent(pinnedAgent: ChatAgent | undefined) {
        this._pinnedAgent = pinnedAgent;
        this.scheduleUpdateReceivingAgent();
        this.update();
    }

    protected onDidResizeEmitter = new Emitter<void>();
    readonly onDidResize = this.onDidResizeEmitter.event;

    @postConstruct()
    protected init(): void {
        this.id = AIChatInputWidget.ID;
        this.title.closable = false;
        this.toDispose.push(this.resources.add(this.getResourceUri(), ''));
        this.toDispose.push(this.aiActivationService.onDidChangeCanRun(() => {
            this.setEnabled(this.aiActivationService.canRun);
        }));
        this.toDispose.push(this.chatAgentService.onDefaultAgentChanged(() => {
            this.scheduleUpdateReceivingAgent();
        }));
        this.toDispose.push(this.onDidResizeEmitter);
        this.toDispose.push(Disposable.create(() => {
            if (this.updateReceivingAgentTimeout !== undefined) {
                clearTimeout(this.updateReceivingAgentTimeout);
                this.updateReceivingAgentTimeout = undefined;
            }
        }));
        this.setEnabled(this.aiActivationService.canRun);
        this.historyService.init().then(() => {
            this.navigationState = new ChatInputNavigationState(this.historyService);
        });
        this.initializeContextKeys();
        this.tokenUsageEnabled = this.preferenceService?.get<boolean>(CHAT_VIEW_TOKEN_USAGE_ENABLED, false) ?? false;
        if (this.preferenceService) {
            this.toDispose.push(this.preferenceService.onPreferenceChanged(change => {
                if (change.preferenceName === CHAT_VIEW_TOKEN_USAGE_ENABLED) {
                    this.tokenUsageEnabled = this.preferenceService?.get<boolean>(CHAT_VIEW_TOKEN_USAGE_ENABLED, false) ?? false;
                    this.update();
                } else if (change.preferenceName === PREFERENCE_NAME_REASONING) {
                    // Refresh the reasoning selector display when the default preference changes.
                    this.update();
                } else if (change.preferenceName === CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE) {
                    // Threshold changed: clear notified sessions so users are warned again
                    // at the new threshold (e.g. after raising it from the warning's
                    // "Open Settings" action), and re-evaluate the current session so the
                    // warning appears immediately if it's still above the new threshold.
                    this.notifiedSessions.clear();
                    if (this._chatModel) {
                        this.evaluateTokenUsageWarning(this._chatModel);
                    }
                    // Re-render so the indicator's color bands reflect the new threshold.
                    this.update();
                } else if (change.preferenceName === CHAT_VIEW_TOKEN_USAGE_WARNING_ENABLED && this._chatModel) {
                    // If the user just enabled warnings for a session already above threshold,
                    // evaluate now so they get an immediate notification instead of waiting for
                    // the next response.
                    this.evaluateTokenUsageWarning(this._chatModel);
                }
            }));
        }
        // Listen for prompt fragment changes to refresh capabilities
        this.toDispose.push(this.capabilitiesService.onDidChangeCapabilities(() => {
            this.refreshCapabilities();
        }));

        // Refresh reasoning capability and the model selector list if the language model registry
        // changes (model added/removed/alias re-resolved).
        this.toDispose.push(this.languageModelRegistry.onChange(() => {
            this.loadAvailableModels().then(() => this.update());
            if (this.receivingAgent) {
                this.updateReasoningSupport(this.receivingAgent.agentId);
            }
        }));
        // When the agent's model is changed in the AI configuration, refresh the selector's resolved
        // default and the model-dependent state (reasoning support, context size, server tools, vendor).
        this.toDispose.push(this.aiSettingsService.onDidChange(() => {
            this.updateResolvedDefaultModel();
            this.updateReasoningSupport(this.receivingAgent?.agentId);
        }));
        this.loadAvailableModels().then(() => this.update());
        this.updateResolvedDefaultModel();

        // When the default mode changes externally (e.g. via AI Configuration),
        // sync the mode selector. Deferred via queueMicrotask so the prompt service's
        // internal state is fully updated before we read agent.modes.
        this.toDispose.push(this.promptService.onSelectedVariantChange(() => {
            queueMicrotask(() => this.syncSelectedModeWithDefault());
        }));

        // Listen for generic capabilities changes
        if (this.genericCapabilitiesService) {
            this.updateAvailableGenericCapabilities();
            this.toDispose.push(this.genericCapabilitiesService.onDidChangeAvailableCapabilities(() => {
                this.updateAvailableGenericCapabilities();
            }));
        }

        this.update();
    }

    protected initializeContextKeys(): void {
        this.chatInputFocusKey = this.contextKeyService.createKey<boolean>('chatInputFocus', false);
        this.chatInputFirstLineKey = this.contextKeyService.createKey<boolean>('chatInputFirstLine', false);
        this.chatInputLastLineKey = this.contextKeyService.createKey<boolean>('chatInputLastLine', false);
        this.chatInputReceivingAgentKey = this.contextKeyService.createKey<string>('chatInputReceivingAgent', '');
        this.chatInputHasModesKey = this.contextKeyService.createKey<boolean>('chatInputHasModes', false);
    }

    updateCursorPositionKeys(): void {
        if (!this.editorRef) {
            this.chatInputFirstLineKey.set(false);
            this.chatInputLastLineKey.set(false);
            return;
        }

        const editor = this.editorRef.getControl();
        const position = editor.getPosition();
        const model = editor.getModel();

        if (!position || !model) {
            this.chatInputFirstLineKey.set(false);
            this.chatInputLastLineKey.set(false);
            return;
        }

        const line = position.lineNumber;
        const col = position.column;

        const topAtPos = editor.getTopForPosition(line, col);
        const topAtLineStart = editor.getTopForLineNumber(line);
        const topAtLineEnd = editor.getTopForPosition(line, model.getLineMaxColumn(line));
        const lineHeight = editor.getOption(EditorOption.lineHeight);
        const toleranceValue = 0.5;

        const isFirstVisualOfThisLine = Math.abs(topAtPos - topAtLineStart) < toleranceValue;
        const isLastVisualOfThisLine = Math.abs(topAtPos - topAtLineEnd) < toleranceValue || (topAtPos > topAtLineEnd - lineHeight + toleranceValue);
        const isFirstVisualOverall = line === 1 && isFirstVisualOfThisLine;

        const isLastVisualOverall = line === model.getLineCount() && isLastVisualOfThisLine;

        this.chatInputFirstLineKey.set(isFirstVisualOverall);
        this.chatInputLastLineKey.set(isLastVisualOverall);
    }

    protected getContextWindowSize(): number {
        return this.currentMaxInputTokens ?? CHAT_CONTEXT_WINDOW_SIZE_FALLBACK;
    }

    /** Converts the percentage-based preference to an absolute token count. */
    protected getTokenUsageWarningThreshold(): number {
        const percentage = this.getTokenUsageWarningThresholdPercentage();
        return Math.round((percentage / 100) * this.getContextWindowSize());
    }

    protected getTokenUsageWarningThresholdPercentage(): number {
        const value = this.preferenceService?.get<number>(
            CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE,
            CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE_DEFAULT
        );
        if (typeof value !== 'number' || !Number.isFinite(value) || value < 1 || value > 100) {
            return CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE_DEFAULT;
        }
        return value;
    }

    protected isTokenUsageWarningEnabled(): boolean {
        return this.preferenceService?.get<boolean>(CHAT_VIEW_TOKEN_USAGE_WARNING_ENABLED, false) ?? false;
    }

    /**
     * Called after a response changes on the currently attached session. Shows a
     * warning the first time the total crosses the threshold, and re-arms when
     * usage drops back below it.
     */
    protected evaluateTokenUsageWarning(chatModel: ChatModel): void {
        // No point doing any work if the feature is off.
        if (!this.isTokenUsageWarningEnabled()) {
            return;
        }
        // `responseChanged` fires on every streaming tick, but providers typically
        // only set `tokenUsage` at completion. Skip in-progress responses so we do
        // the walk + decision once per response rather than per chunk.
        const lastRequest = chatModel.getRequests().at(-1);
        if (lastRequest && !lastRequest.response.isComplete) {
            return;
        }
        const decision = decideTokenUsageWarning({
            totalTokens: computeSessionTokenUsage(chatModel),
            threshold: this.getTokenUsageWarningThreshold(),
            alreadyNotified: this.notifiedSessions.has(chatModel.id)
        });
        if (decision === 'reset') {
            this.notifiedSessions.delete(chatModel.id);
        } else if (decision === 'notify') {
            this.notifiedSessions.add(chatModel.id);
            this.showTokenUsageWarning();
        }
    }

    protected async showTokenUsageWarning(): Promise<void> {
        const percentage = this.getTokenUsageWarningThresholdPercentage();
        const message = nls.localize(
            'theia/ai/chat-ui/tokenUsageWarningMessage',
            'Chat session token usage has reached {0}% of the context window. ' +
            'Consider summarizing this session or starting a new one to avoid hitting the limit.',
            percentage
        );
        const summarizeAction = nls.localize('theia/ai/chat-ui/tokenUsageWarningSummarizeAction', 'Summarize Current Session');
        const newSessionAction = nls.localize('theia/ai/chat-ui/tokenUsageWarningNewSessionAction', 'Start New Chat');
        const openSettingsAction = nls.localizeByDefault('Open Settings');
        const selected = await this.messageService.warn(message, summarizeAction, newSessionAction, openSettingsAction);
        if (selected === summarizeAction) {
            this.commandService.executeCommand(ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT.id).catch(error => {
                console.error(`Failed to execute '${ChatCommands.AI_CHAT_NEW_WITH_TASK_CONTEXT.id}' from token usage warning`, error);
            });
        } else if (selected === newSessionAction) {
            this.commandService.executeCommand(AI_CHAT_HOME.id).catch(error => {
                console.error(`Failed to execute '${AI_CHAT_HOME.id}' from token usage warning`, error);
            });
        } else if (selected === openSettingsAction) {
            this.commandService.executeCommand(CommonCommands.OPEN_PREFERENCES.id, CHAT_VIEW_TOKEN_USAGE_WARNING_THRESHOLD_PERCENTAGE).catch(error => {
                console.error(`Failed to execute '${CommonCommands.OPEN_PREFERENCES.id}' from token usage warning`, error);
            });
        }
    }

    protected scheduleUpdateReceivingAgent(): void {
        if (this.queryInFlight) {
            // Don't update capabilities while a query is being sent — the editor is being
            // cleared and the async sendRequest hasn't set session.pinnedAgent yet,
            // which would cause a stale resolution to the default agent.
            return;
        }
        if (this.updateReceivingAgentTimeout !== undefined) {
            clearTimeout(this.updateReceivingAgentTimeout);
        }
        this.updateReceivingAgentTimeout = window.setTimeout(() => {
            this.updateReceivingAgent();
            this.updateReceivingAgentTimeout = undefined;
        }, 200);
    }

    protected async updateReceivingAgent(): Promise<void> {
        if (!this.editorRef || !this._chatModel) {
            if (this.receivingAgent !== undefined) {
                this.chatInputReceivingAgentKey.set('');
                this.chatInputHasModesKey.set(false);
                this.receivingAgent = undefined;
                this.update();
            }
            return;
        }

        try {
            const inputText = this.editorRef.getControl().getValue();
            const request = { text: inputText };
            const resolvedContext = { variables: [] };
            const parsedRequest = await this.chatRequestParser.parseChatRequest(request, this._chatModel.location, resolvedContext);
            const session = this.chatService.getSessions().find(s => s.model.id === this._chatModel.id);

            if (session) {
                const resolvedAgent = this.resolveAgentFromParsedRequest(parsedRequest, session);
                // Prefer the widget's pinned agent over the resolved agent, because
                // chatService.getAgent() may return the default preference agent
                // instead of the session's pinned agent (e.g. after sending a message
                // when the input is empty).
                const agent = this._pinnedAgent ?? resolvedAgent;
                await this.updateAgentState(agent);
            } else if (this.receivingAgent !== undefined) {
                this.chatInputReceivingAgentKey.set('');
                this.chatInputHasModesKey.set(false);
                this.receivingAgent = undefined;
                this.update();
            }
        } catch (error) {
            this.logger.warn('Failed to determine receiving agent:', error);
            if (this.receivingAgent !== undefined) {
                this.chatInputReceivingAgentKey.set('');
                this.chatInputHasModesKey.set(false);
                this.receivingAgent = undefined;
                this.update();
            }
        }
    }

    /**
     * Resolves the agent to use for the given parsed request.
     * If a session is provided, uses session-based logic including pinned agents.
     * Otherwise, delegates to ChatAgentService.
     */
    protected resolveAgentFromParsedRequest(
        parsedRequest: ParsedChatRequest,
        session?: ChatSession
    ): ChatAgent | undefined {
        if (session) {
            return this.chatService.getAgent(parsedRequest, session);
        }
        return this.chatAgentService.resolveAgent(parsedRequest);
    }

    /**
     * Updates the receiving agent state and triggers re-render if the agent changed.
     */
    protected async updateAgentState(agent: ChatAgent | undefined): Promise<void> {
        const agentId = agent?.id ?? '';
        const previousAgentId = this.receivingAgent?.agentId;

        this.chatInputReceivingAgentKey.set(agentId);

        // Only update and re-render when the agent changes (or on forced refresh after session switch)
        const needsRefresh = this.forceCapabilitiesRefresh;
        this.forceCapabilitiesRefresh = false;
        if (agent && (agentId !== previousAgentId || needsRefresh)) {
            const modes = agent.modes ?? [];
            const defaultMode = modes.find(m => m.isDefault);
            const hasPreviousRequests = this._chatModel.getRequests().length > 0;
            const restoredModeId = needsRefresh && hasPreviousRequests
                ? this.getLastModeIdFromModel(this._chatModel)
                : undefined;
            const initialModeId = restoredModeId ?? defaultMode?.id;
            this.receivingAgent = {
                agentId: agentId,
                modes,
                currentModeId: initialModeId
            };
            this.chatInputHasModesKey.set(modes.length > 1);
            // Only preserve overrides on forced refresh if the session has previous requests
            const shouldPreserveOverrides = needsRefresh && hasPreviousRequests;
            await this.updateCapabilitiesForAgent(agentId, initialModeId, shouldPreserveOverrides);
            this.updateReasoningSupport(agentId);
        } else if (!agent && this.receivingAgent !== undefined) {
            this.receivingAgent = undefined;
            this.capabilityDefaults = [];
            this.userCapabilityOverrides = new Map();
            this.chatInputHasModesKey.set(false);
            this.currentReasoningSupport = undefined;
            this.currentServerTools = undefined;
            this.currentModelVendor = undefined;
            this.update();
        }
    }

    /**
     * Syncs the selected mode in the UI with the agent's current default mode.
     * Called when the default mode changes externally (e.g. via AI Configuration).
     */
    protected syncSelectedModeWithDefault(): void {
        if (!this.receivingAgent) {
            return;
        }
        const agent = this.chatAgentService.getAgent(this.receivingAgent.agentId);
        if (!agent?.modes) {
            return;
        }
        const updatedModes = agent.modes;
        const newDefault = updatedModes.find(m => m.isDefault);
        if (newDefault && newDefault.id !== this.receivingAgent.currentModeId) {
            this.receivingAgent = {
                ...this.receivingAgent,
                modes: updatedModes,
                currentModeId: newDefault.id
            };
            this.update();
        }
    }

    protected setupEditorEventListeners(): void {
        if (!this.editorRef) {
            return;
        }

        const editor = this.editorRef.getControl();

        this.toDispose.push(editor.onDidFocusEditorWidget(() => {
            this.chatInputFocusKey.set(true);
            this.chatInputFocusService.setFocused(this);
            this.updateCursorPositionKeys();
        }));

        this.toDispose.push(editor.onDidBlurEditorWidget(() => {
            this.chatInputFocusService.clearFocused(this);
            if (this.chatInputFocusService.getFocused() === undefined) {
                this.chatInputFocusKey.set(false);
                this.chatInputFirstLineKey.set(false);
                this.chatInputLastLineKey.set(false);
            }
        }));

        this.toDispose.push(editor.onDidChangeCursorPosition(() => {
            if (editor.hasWidgetFocus()) {
                this.updateCursorPositionKeys();
            }
        }));

        this.toDispose.push(editor.onDidChangeModelContent(() => {
            if (editor.hasWidgetFocus()) {
                this.updateCursorPositionKeys();
            }
            this.scheduleUpdateReceivingAgent();
        }));

        // Force the suggest widget above the input, otherwise Monaco measures the available
        // space against the document body and opens it downward behind the bottom panel when
        // the chat is in the main area. Assert this on every trigger because the inline
        // completions controller resets the flag whenever there is no ghost text.
        const suggestController = SuggestController.get(editor);
        if (suggestController) {
            this.toDispose.push(suggestController.model.onDidTrigger(() => suggestController.forceRenderingAbove()));
        }

        if (editor.hasWidgetFocus()) {
            this.chatInputFocusKey.set(true);
            this.updateCursorPositionKeys();
        }
    }

    protected override onActivateRequest(msg: Message): void {
        super.onActivateRequest(msg);
        this.editorReady.promise.then(() => {
            if (this.editorRef) {
                this.editorRef.focus();
            }
        });
    }

    protected getResourceUri(): URI {
        return new URI(`ai-chat:/input.${CHAT_VIEW_LANGUAGE_EXTENSION}`);
    }

    protected render(): React.ReactNode {
        const branch = this._branch;
        const chatModel = this._chatModel;

        // State of the input widget's action buttons depends on the state of the currently active or last processed
        // request, if there is one. If the chat model has branched, then the current request is the last on the
        // branch. Otherwise, it's the last request in the chat model.
        const currentRequest: ChatRequestModel | undefined = branch?.items?.at(-1)?.element ?? chatModel.getRequests().at(-1);
        const isEditing = !!(currentRequest && (EditableChatRequestModel.isEditing(currentRequest)));
        const isPending = () => !!(currentRequest && !isEditing && ChatRequestModel.isInProgress(currentRequest));
        const pending = isPending();

        return (
            <ChatInput
                branch={this._branch}
                onQuery={this._onQuery.bind(this)}
                onUnpin={this._onUnpin.bind(this)}
                onCancel={this._onCancel.bind(this)}
                onDragOver={this.onDragOver.bind(this)}
                onDrop={this.onDrop.bind(this)}
                onPaste={this.onPaste.bind(this)}
                onEscape={this.onEscape.bind(this)}
                onDeleteChangeSet={this._onDeleteChangeSet.bind(this)}
                onDeleteChangeSetElement={this._onDeleteChangeSetElement.bind(this)}
                onAddContextElement={this.addContextElement.bind(this)}
                onDeleteContextElement={this.deleteContextElement.bind(this)}
                onOpenContextElement={this.openContextElement.bind(this)}
                context={this.getContext()}
                fileValidationState={this.fileValidationState}
                chatModel={this._chatModel}
                pinnedAgent={this._pinnedAgent}
                editorProvider={this.editorProvider}
                uri={this.getResourceUri()}
                contextMenuCallback={this.handleContextMenu.bind(this)}
                isEnabled={this.isEnabled}
                setEditorRef={editor => {
                    this.editorRef = editor;
                    this.setupEditorEventListeners();
                    this.editorReady.resolve();
                    this.scheduleUpdateReceivingAgent();
                }}
                showContext={this.configuration?.showContext}
                showPinnedAgent={this.configuration?.showPinnedAgent}
                showChangeSet={this.configuration?.showChangeSet}
                showSuggestions={this.configuration?.showSuggestions}
                showCapabilities={this.configuration?.showCapabilities}
                hasPromptHistory={this.configuration?.enablePromptHistory}
                labelProvider={this.labelProvider}
                actionService={this.changeSetActionService}
                decoratorService={this.changeSetDecoratorService}
                initialValue={this._initialValue}
                openerService={this.openerService}
                suggestions={this._chatModel.suggestions}
                currentRequest={currentRequest}
                isEditing={isEditing}
                pending={pending}
                heightInLines={this.heightInLines}
                onResponseChanged={() => {
                    if (isPending() !== pending) {
                        this.update();
                    }
                }}
                onResize={() => this.onDidResizeEmitter.fire()}
                hoverService={this.hoverService}
                modeSelectorProps={{
                    receivingAgentModes: this.receivingAgent?.modes,
                    currentMode: this.receivingAgent?.currentModeId,
                    onModeChange: this.handleModeChange,
                    keybindingHint: this.getModeKeybindingHint(),
                }}
                reasoningSelectorProps={{
                    reasoningSupport: this.currentReasoningSupport,
                    currentLevel: this.getCurrentReasoningLevel(),
                    onReasoningChange: this.handleReasoningChange,
                }}
                modelSelectorProps={this.getModelSelectorProps()}
                capabilitiesProps={{
                    capabilities: this.capabilityDefaults,
                    overrides: this.userCapabilityOverrides,
                    onCapabilityChange: this.handleCapabilityChange,
                    isOpen: this.capabilitiesOpen,
                    onToggle: () => this.toggleCapabilities(),
                    keybindingHint: this.getCapabilitiesKeybindingHint(),
                    hasUnsavedChanges: this.hasAnyChangesFromSaved(),
                    onSaveToSettings: () => this.saveCurrentSelectionsToSettings(),
                }}
                genericCapabilitiesProps={{
                    genericCapabilities: this.genericCapabilitySelections,
                    onGenericCapabilityChange: this.handleGenericCapabilityChange,
                    onResetGenericCapabilities: this.handleResetGenericCapabilities,
                    availableCapabilities: this.availableGenericCapabilities,
                    disabledCapabilities: this.disabledGenericCapabilities,
                    serverTools: this.getServerToolsSection(),
                    serverToolSelections: this.serverToolSelections,
                    hoverService: this.hoverService,
                }}
                tokenUsageEnabled={this.tokenUsageEnabled}
                tokenUsageWarningThreshold={this.getTokenUsageWarningThreshold()}
                contextWindowSize={this.getContextWindowSize()}
            />
        );
    }

    protected onDragOver(event: React.DragEvent): void {
        event.preventDefault();
        event.stopPropagation();
        this.node.classList.add('drag-over');
        if (event.dataTransfer?.types.includes('text/plain')) {
            event.dataTransfer!.dropEffect = 'copy';
        } else {
            event.dataTransfer!.dropEffect = 'link';
        }
    }

    protected onDrop(event: React.DragEvent): void {
        event.preventDefault();
        event.stopPropagation();
        this.node.classList.remove('drag-over');
        const dataTransferText = event.dataTransfer?.getData('text/plain');
        const position = this.editorRef?.getControl().getTargetAtClientPoint(event.clientX, event.clientY)?.position;
        this.variableService.getDropResult(event.nativeEvent, { type: 'ai-chat-input-widget' }).then(result => {
            const textsToInsert: string[] = [];

            result.variables.forEach(variable => {
                if (ImageContextVariable.isImageContextRequest(variable)) {
                    // Register with short ID and insert short reference at drop position
                    const shortId = this.registerPendingImage(variable);
                    textsToInsert.push(`#${variable.variable.name}:${shortId}`);
                } else {
                    this.addContext(variable);
                }
            });

            // Add any text from drop result or data transfer
            if (result.text) {
                textsToInsert.push(result.text);
            } else if (dataTransferText && textsToInsert.length === 0) {
                // Only use dataTransferText if we have nothing else to insert
                textsToInsert.push(dataTransferText);
            }

            if (position && textsToInsert.length > 0) {
                this.editorRef?.getControl().executeEdits('drag-and-drop', [{
                    range: {
                        startLineNumber: position.lineNumber,
                        startColumn: position.column,
                        endLineNumber: position.lineNumber,
                        endColumn: position.column
                    },
                    text: textsToInsert.join(' ')
                }]);
            }
        });
    }

    protected onPaste(event: ClipboardEvent): void {
        // Check synchronously whether the clipboard contains images.
        // If so, prevent the default paste to avoid Monaco also inserting
        // the text representation of the clipboard alongside our image reference.
        const hasImages = this.clipboardDataHasImages(event.clipboardData);
        if (hasImages) {
            event.preventDefault();
        }
        this.variableService.getPasteResult(event, { type: 'ai-chat-input-widget' }).then(result => {
            this.processPasteResult(result);
        });
    }

    /**
     * Paste images from the clipboard using the async Clipboard API.
     * Called from the keybinding contribution when Ctrl+V is pressed in the chat input,
     * because Electron/Theia's paste command (document.execCommand('paste')) does not
     * produce a DOM paste event for image clipboard content.
     * @returns true if images were found and pasted, false otherwise.
     */
    async pasteFromClipboard(): Promise<boolean> {
        const result = await this.variableService.getPasteResult(
            new ClipboardEvent('paste'), { type: 'ai-chat-input-widget' }
        );
        const hasImages = result.variables.some(v => ImageContextVariable.isImageContextRequest(v));
        if (hasImages) {
            this.processPasteResult(result);
        }
        return hasImages;
    }

    protected processPasteResult(result: { variables: AIVariableResolutionRequest[], text?: string }): void {
        const position = this.editorRef?.getControl().getPosition();
        const textsToInsert: string[] = [];

        result.variables.forEach(variable => {
            if (ImageContextVariable.isImageContextRequest(variable)) {
                // Register with short ID and insert short reference at cursor position
                const shortId = this.registerPendingImage(variable);
                textsToInsert.push(`#${variable.variable.name}:${shortId}`);
            } else {
                this.addContext(variable);
            }
        });

        // Insert any text from the paste result
        if (result.text) {
            textsToInsert.push(result.text);
        }

        // Insert all collected text at cursor position
        if (position && textsToInsert.length > 0) {
            this.editorRef?.getControl().executeEdits('paste', [{
                range: {
                    startLineNumber: position.lineNumber,
                    startColumn: position.column,
                    endLineNumber: position.lineNumber,
                    endColumn: position.column
                },
                text: textsToInsert.join(' ')
            }]);
        }
    }

    protected clipboardDataHasImages(clipboardData: DataTransfer | null): boolean {
        if (!clipboardData?.items) {
            return false;
        }
        for (const item of clipboardData.items) {
            if (item.type.startsWith('image/')) {
                return true;
            }
        }
        return false;
    }

    protected onEscape(): void {
        const currentRequest = this._branch?.items?.at(-1)?.element ?? this._chatModel.getRequests().at(-1);
        if (currentRequest && !EditableChatRequestModel.isEditing(currentRequest) && ChatRequestModel.isInProgress(currentRequest)) {
            this._onCancel(currentRequest);
        }
    }

    protected async openContextElement(request: AIVariableResolutionRequest): Promise<void> {
        // Re-validate file before opening
        if (request.variable.name === 'file' && request.arg) {
            if (this.validationService) {
                const result = await this.validationService.validateFile(request.arg);
                this.fileValidationState.set(request.arg, result);
                this.update();
            }
        }
        const session = this.chatService.getSessions().find(candidate => candidate.model.id === this._chatModel.id);
        const context = { session };
        await this.variableService.open(request, context);
    }

    public setEnabled(enabled: boolean): void {
        this.isEnabled = enabled;
        this.update();
    }

    protected addContextElement(): void {
        this.contextVariablePicker.pickContextVariable().then(contextElement => {
            if (contextElement) {
                this.addContext(contextElement);
            }
        });
    }

    protected handleContextMenu(event: IMouseEvent): void {
        this.contextMenuRenderer.render({
            menuPath: AIChatInputWidget.CONTEXT_MENU,
            anchor: { x: event.posx, y: event.posy },
            context: event.target,
            args: [this.editorRef]
        });
        event.preventDefault();
    }

    addContext(variable: AIVariableResolutionRequest): void {
        // Validation happens in the chatModel.onDidChange listener
        this._chatModel.context.addVariables(variable);
    }

    /**
     * Get the scope URI used for registering pending images.
     * Uses the chat model ID for scoping, not the editor URI.
     */
    protected getScopeUri(): string {
        const modelId = this._chatModel?.id ?? 'default';
        return this.pendingImageRegistry.getScopeUriForModel(modelId);
    }

    /**
     * Register a pending image attachment with a short ID.
     * @returns The short ID that can be used in the text.
     */
    registerPendingImage(variable: AIVariableResolutionRequest): string {
        const parsed = ImageContextVariable.parseRequest(variable);
        if (!parsed || !variable.arg) {
            throw new Error('Invalid image context variable');
        }

        const scopeUri = this.getScopeUri();
        const baseName = this.getImageBaseName(parsed);
        const shortId = this.pendingImageRegistry.generateShortId(baseName, scopeUri);

        const disposable = this.pendingImageRegistry.register(
            scopeUri,
            shortId,
            parsed,
            variable.arg
        );
        this.pendingImageDisposables.set(shortId, disposable);

        return shortId;
    }

    /**
     * Clear all pending image attachments. Called after a message is sent.
     */
    clearPendingImageAttachments(): void {
        // Dispose all registrations
        for (const disposable of this.pendingImageDisposables.values()) {
            disposable.dispose();
        }
        this.pendingImageDisposables.clear();

        // Also clear the scope in the registry (belt and suspenders)
        this.pendingImageRegistry.clearScope(this.getScopeUri());
    }

    /**
     * Get a meaningful base name for an image variable.
     * For dropped files (with wsRelativePath), use the workspace-relative path (like file variables).
     * For pasted images, use "pasted_image".
     */
    protected getImageBaseName(imageVariable: ImageContextVariable): string {
        // For file-based images, use the workspace-relative path (consistent with #file: variables)
        if (imageVariable.wsRelativePath) {
            return imageVariable.wsRelativePath;
        }

        // For pasted images, use a generic name
        return 'pasted_image';
    }

    /**
     * Get all variables to be sent with the request.
     * Note: Image variables in text use short IDs that get resolved via the pending image registry.
     */
    getAllVariablesForRequest(): AIVariableResolutionRequest[] {
        return [...this._chatModel.context.getVariables()];
    }

    protected getContext(): readonly AIVariableResolutionRequest[] {
        return this._chatModel.context.getVariables();
    }

    /**
     * Delete a context element by index.
     */
    protected deleteContextElement(index: number): void {
        this._chatModel.context.deleteVariables(index);
    }
}

/** Props for the per-session language model selector. */
interface ModelSelectorWidgetProps {
    /** Models available to switch to. */
    models: LanguageModel[];
    /** The session's current model override id, if any (undefined = use the agent default). */
    currentModelId?: string;
    /** Human-readable label of the model/alias new sessions use by default. */
    defaultLabel: string;
    /** Set the session override (or clear it with `undefined`). */
    onModelChange: (modelId: string | undefined) => void;
}

interface ChatInputProperties {
    branch?: ChatHierarchyBranch;
    onCancel: (requestModel: ChatRequestModel) => void;
    onQuery: (query: string, mode?: string, capabilityOverrides?: Record<string, boolean>, genericCapabilitySelections?: GenericCapabilitySelections,
        serverToolSelections?: Record<string, string[]>) => void;
    onUnpin: () => void;
    onDragOver: (event: React.DragEvent) => void;
    onDrop: (event: React.DragEvent) => void;
    onPaste: (event: ClipboardEvent) => void;
    onDeleteChangeSet: (sessionId: string) => void;
    onDeleteChangeSetElement: (sessionId: string, uri: URI) => void;
    onAddContextElement: () => void;
    onDeleteContextElement: (index: number) => void;
    onEscape: () => void;
    onOpenContextElement: OpenContextElement;
    context?: readonly AIVariableResolutionRequest[];
    fileValidationState: Map<string, FileValidationResult>;
    isEnabled?: boolean;
    chatModel: ChatModel;
    pinnedAgent?: ChatAgent;
    editorProvider: MonacoEditorProvider;
    uri: URI;
    contextMenuCallback: (event: IMouseEvent) => void;
    setEditorRef: (editor: SimpleMonacoEditor | undefined) => void;
    showContext?: boolean;
    showPinnedAgent?: boolean;
    showChangeSet?: boolean;
    showSuggestions?: boolean;
    showCapabilities?: boolean;
    hasPromptHistory?: boolean;
    labelProvider: LabelProvider;
    actionService: ChangeSetActionService;
    decoratorService: ChangeSetDecoratorService;
    initialValue?: string;
    openerService: OpenerService;
    suggestions: readonly ChatSuggestion[];
    currentRequest?: ChatRequestModel;
    isEditing: boolean;
    pending: boolean;
    heightInLines?: number;
    onResponseChanged: () => void;
    onResize: () => void;
    hoverService: HoverService;
    modeSelectorProps: {
        receivingAgentModes?: ChatMode[];
        currentMode?: string;
        onModeChange: (mode: string) => void;
        keybindingHint?: string;
    };
    reasoningSelectorProps: {
        reasoningSupport?: ReasoningSupport;
        currentLevel?: ReasoningLevel;
        onReasoningChange: (level: ReasoningLevel) => void;
    };
    modelSelectorProps: ModelSelectorWidgetProps;
    capabilitiesProps: {
        capabilities: ParsedCapability[];
        overrides: Map<string, boolean>;
        onCapabilityChange: (fragmentId: string, enabled: boolean) => void;
        isOpen: boolean;
        onToggle: () => void;
        keybindingHint?: string;
        hasUnsavedChanges: boolean;
        onSaveToSettings: () => void;
    };
    genericCapabilitiesProps: {
        genericCapabilities: GenericCapabilitySelections;
        onGenericCapabilityChange: (type: keyof GenericCapabilitySelections, ids: string[]) => void;
        onResetGenericCapabilities: () => void;
        availableCapabilities: AvailableGenericCapabilities;
        disabledCapabilities: GenericCapabilitySelections;
        /** Optional server tools section for the current model's provider. */
        serverTools?: ServerToolsSection;
        /** Full server tool selection record (keyed by vendor), sent with the request. */
        serverToolSelections: Record<string, string[]>;
        hoverService: HoverService;
    };
    tokenUsageEnabled?: boolean;
    tokenUsageWarningThreshold: number;
    contextWindowSize: number;
}

// Utility to check if we have task context in the chat model
const hasTaskContext = (chatModel: ChatModel): boolean => chatModel.context.getVariables().some(variable =>
    variable.variable?.id === TASK_CONTEXT_VARIABLE.id
);

const ChatInput: React.FunctionComponent<ChatInputProperties> = (props: ChatInputProperties) => {
    const onDeleteChangeSet = () => props.onDeleteChangeSet(props.chatModel.id);
    const onDeleteChangeSetElement = (uri: URI) => props.onDeleteChangeSetElement(props.chatModel.id, uri);

    const [isInputEmpty, setIsInputEmpty] = React.useState(true);
    const [isInputFocused, setIsInputFocused] = React.useState(false);
    const [placeholderText, setPlaceholderText] = React.useState('');
    const [changeSetUI, setChangeSetUI] = React.useState(
        () => buildChangeSetUI(
            props.chatModel.changeSet,
            props.labelProvider,
            props.decoratorService,
            props.actionService.getActionsForChangeset(props.chatModel.changeSet),
            onDeleteChangeSet,
            onDeleteChangeSetElement
        ));

    // eslint-disable-next-line no-null/no-null
    const editorContainerRef = React.useRef<HTMLDivElement | null>(null);
    // eslint-disable-next-line no-null/no-null
    const placeholderRef = React.useRef<HTMLDivElement | null>(null);
    const editorRef = React.useRef<SimpleMonacoEditor | undefined>(undefined);
    // eslint-disable-next-line no-null/no-null
    const containerRef = React.useRef<HTMLDivElement>(null);

    // On the first request of the chat, if the chat has a task context and a pinned
    // agent, show a "Perform this task." placeholder which is the message to send by default
    const isFirstRequest = props.chatModel.getRequests().length === 0;
    const shouldUseTaskPlaceholder = isFirstRequest && props.pinnedAgent && hasTaskContext(props.chatModel);
    const taskPlaceholder = nls.localize('theia/ai/chat-ui/performThisTask', 'Perform this task.');

    // Update placeholder text when focus state or other dependencies change
    React.useEffect(() => {
        const newPlaceholderText = !props.isEnabled
            ? nls.localize('theia/ai/chat-ui/aiDisabled', 'AI features are disabled')
            : shouldUseTaskPlaceholder
                ? taskPlaceholder
                : nls.localize('theia/ai/chat-ui/askQuestion', 'Ask a question') +
                (props.hasPromptHistory && isInputFocused ? nls.localizeByDefault(' ({0} for history)', '⇅') : '');
        setPlaceholderText(newPlaceholderText);
    }, [props.isEnabled, shouldUseTaskPlaceholder, taskPlaceholder, props.hasPromptHistory, isInputFocused]);

    // Handle paste events on the container
    const handlePaste = React.useCallback((event: ClipboardEvent) => {
        props.onPaste(event);
    }, [props.onPaste]);

    // Set up paste handler on the container div
    React.useEffect(() => {
        const container = containerRef.current;
        if (container) {
            container.addEventListener('paste', handlePaste, true);
            return () => {
                container.removeEventListener('paste', handlePaste, true);
            };
        }
        return undefined;
    }, [handlePaste]);

    React.useEffect(() => {
        const uri = props.uri;
        const createInputElement = async () => {
            const paddingTop = 6;
            const lineHeight = 20;
            const maxHeightPx = (props.heightInLines ?? 12) * lineHeight;

            const editor = await props.editorProvider.createSimpleInline(uri, editorContainerRef.current!, {
                language: CHAT_VIEW_LANGUAGE_EXTENSION,
                // Disable code lens and inlay hints to avoid console errors from other contributions
                codeLens: false,
                inlayHints: { enabled: 'off' },
                hover: { enabled: 'on' },
                autoSizing: false, // we handle the sizing ourselves
                scrollBeyondLastLine: false,
                scrollBeyondLastColumn: 0,
                minHeight: 1,
                fontFamily: 'var(--theia-ui-font-family)',
                fontSize: 13,
                cursorWidth: 1,
                maxHeight: -1,
                scrollbar: { horizontal: 'hidden', alwaysConsumeMouseWheel: false, handleMouseWheel: true },
                automaticLayout: true,
                lineNumbers: 'off',
                lineHeight,
                padding: { top: paddingTop },
                suggest: {
                    showIcons: true,
                    showSnippets: false,
                    showWords: false,
                    showStatusBar: false,
                    insertMode: 'replace',
                },
                bracketPairColorization: { enabled: false },
                wrappingStrategy: 'advanced',
                stickyScroll: { enabled: false },
                ariaLabel: nls.localize('theia/ai/chat-ui/chatInputAriaLabel', 'Type your message here'),
            });

            if (editorContainerRef.current) {
                editorContainerRef.current.style.overflowY = 'auto'; // ensure vertical scrollbar
                editorContainerRef.current.style.height = (lineHeight + (2 * paddingTop)) + 'px';

                editorContainerRef.current.addEventListener('wheel', e => {
                    // Prevent parent from scrolling
                    e.stopPropagation();
                }, { passive: false });
            }

            const updateEditorHeight = () => {
                if (editorContainerRef.current) {
                    const contentHeight = editor.getControl().getContentHeight() + paddingTop;
                    editorContainerRef.current.style.height = `${Math.min(contentHeight, maxHeightPx)}px`;
                }
            };

            editor.getControl().onDidChangeModelContent(() => {
                const value = editor.getControl().getValue();
                setIsInputEmpty(!value || value.length === 0);
                updateEditorHeight();
                handleOnChange();
            });
            const resizeObserver = new ResizeObserver(() => {
                updateEditorHeight();
                props.onResize();
            });
            if (editorContainerRef.current) {
                resizeObserver.observe(editorContainerRef.current);
            }
            editor.getControl().onDidDispose(() => {
                resizeObserver.disconnect();
            });

            editor.getControl().onContextMenu(e =>
                props.contextMenuCallback(e.event)
            );

            const updateLineCounts = () => {
                // We need the line numbers to allow scrolling by using the keyboard
                const model = editor.getControl().getModel()!;
                const lineCount = model.getLineCount();
                const decorations: IModelDeltaDecoration[] = [];

                for (let lineNumber = 1; lineNumber <= lineCount; lineNumber++) {
                    decorations.push({
                        range: new Range(lineNumber, 1, lineNumber, 1),
                        options: {
                            description: `line-number-${lineNumber}`,
                            isWholeLine: false,
                            className: `line-number-${lineNumber}`,
                        }
                    });
                }

                const lineNumbers = model.getAllDecorations().filter(predicate => predicate.options.description?.startsWith('line-number-'));
                editor.getControl().removeDecorations(lineNumbers.map(d => d.id));
                editor.getControl().createDecorationsCollection(decorations);
            };

            editor.getControl().getModel()?.onDidChangeContent(() => {
                updateLineCounts();
            });

            editor.getControl().onDidChangeCursorPosition(e => {
                const lineNumber = e.position.lineNumber;
                const line = editor.getControl().getDomNode()?.querySelector(`.line-number-${lineNumber}`);
                line?.scrollIntoView({ behavior: 'instant', block: 'nearest' });
            });

            editorRef.current = editor;
            props.setEditorRef(editor);

            if (props.initialValue) {
                setValue(props.initialValue);
            }

            updateLineCounts();
        };
        createInputElement();

        return () => {
            props.setEditorRef(undefined);
            if (editorRef.current) {
                editorRef.current.dispose();
            }
        };
    }, []);

    React.useEffect(() => {
        setChangeSetUI(buildChangeSetUI(
            props.chatModel.changeSet,
            props.labelProvider,
            props.decoratorService,
            props.actionService.getActionsForChangeset(props.chatModel.changeSet),
            onDeleteChangeSet,
            onDeleteChangeSetElement
        ));
        const listener = props.chatModel.onDidChange(event => {
            if (ChatChangeEvent.isChangeSetEvent(event)) {
                setChangeSetUI(buildChangeSetUI(
                    props.chatModel.changeSet,
                    props.labelProvider,
                    props.decoratorService,
                    props.actionService.getActionsForChangeset(props.chatModel.changeSet),
                    onDeleteChangeSet,
                    onDeleteChangeSetElement
                ));
            }
        });
        return () => {
            listener.dispose();
        };
    }, [props.chatModel, props.labelProvider, props.decoratorService, props.actionService]);

    React.useEffect(() => {
        const disposable = props.actionService.onDidChange(() => {
            const newActions = props.actionService.getActionsForChangeset(props.chatModel.changeSet);
            setChangeSetUI(current => !current ? current : { ...current, actions: newActions });
        });
        return () => disposable.dispose();
    }, [props.actionService, props.chatModel.changeSet]);

    React.useEffect(() => {
        const disposable = props.decoratorService.onDidChangeDecorations(() => {
            setChangeSetUI(buildChangeSetUI(
                props.chatModel.changeSet,
                props.labelProvider,
                props.decoratorService,
                props.actionService.getActionsForChangeset(props.chatModel.changeSet),
                onDeleteChangeSet,
                onDeleteChangeSetElement
            ));
        });
        return () => disposable.dispose();
    });

    const setValue = React.useCallback((value: string) => {
        if (editorRef.current && !editorRef.current.document.isDisposed()) {
            editorRef.current.document.textEditorModel.setValue(value);
        }
    }, [editorRef]);

    // Convert capability overrides map to a plain record - always send all capability states
    const getCapabilityOverridesRecord = React.useCallback((): Record<string, boolean> | undefined => {
        if (props.capabilitiesProps.overrides.size === 0) {
            return undefined;
        }
        const record: Record<string, boolean> = {};
        for (const [key, value] of props.capabilitiesProps.overrides) {
            record[key] = value;
        }
        return record;
    }, [props.capabilitiesProps.overrides]);

    // Get generic capability selections if any are set
    const getGenericCapabilitySelections = React.useCallback((): GenericCapabilitySelections | undefined => {
        const selections = props.genericCapabilitiesProps.genericCapabilities;
        return GenericCapabilitySelections.hasSelections(selections) ? selections : undefined;
    }, [props.genericCapabilitiesProps.genericCapabilities]);

    // Get server tool selections (full record, keyed by vendor) if any are set
    const getServerToolSelections = React.useCallback((): Record<string, string[]> | undefined => {
        const selections = props.genericCapabilitiesProps.serverToolSelections;
        return Object.values(selections).some(ids => ids.length > 0) ? selections : undefined;
    }, [props.genericCapabilitiesProps.serverToolSelections]);

    // Without user input, if we can default to "Perform this task.", do so
    const submit = React.useCallback(function submit(value: string): void {
        let effectiveValue = value;
        if ((!value || value.trim().length === 0) && shouldUseTaskPlaceholder) {
            effectiveValue = taskPlaceholder;
        }
        if (!effectiveValue || effectiveValue.trim().length === 0) {
            return;
        }
        const capabilityOverrides = getCapabilityOverridesRecord();
        const genericCapabilitySelections = getGenericCapabilitySelections();
        const serverToolSelections = getServerToolSelections();
        props.onQuery(effectiveValue, props.modeSelectorProps.currentMode, capabilityOverrides, genericCapabilitySelections, serverToolSelections);
        setValue('');
        if (editorRef.current && !editorRef.current.document.textEditorModel.isDisposed()) {
            editorRef.current.document.textEditorModel.setValue('');
            editorRef.current.focus();
        }
    }, [
        props.context, props.onQuery, props.modeSelectorProps.currentMode, setValue,
        shouldUseTaskPlaceholder, taskPlaceholder, getCapabilityOverridesRecord, getGenericCapabilitySelections, getServerToolSelections
    ]);

    const onKeyDown = React.useCallback((event: React.KeyboardEvent) => {
        if (!props.isEnabled) {
            return;
        }
        if (event.key === 'Enter' && !event.shiftKey) {
            event.preventDefault();
            // On Enter, read input and submit (handles task context)
            const currentValue = editorRef.current?.document.textEditorModel.getValue() || '';
            submit(currentValue);
        } else if (event.key === 'Escape') {
            event.preventDefault();
            props.onEscape();
        }
    }, [props.isEnabled, submit]);

    const handleInputFocus = () => {
        setIsInputFocused(true);
        hidePlaceholderIfEditorFilled();
    };

    const handleOnChange = () => {
        showPlaceholderIfEditorEmpty();
        hidePlaceholderIfEditorFilled();
    };

    const handleInputBlur = () => {
        setIsInputFocused(false);
        showPlaceholderIfEditorEmpty();
    };

    const showPlaceholderIfEditorEmpty = () => {
        if (!editorRef.current?.getControl().getValue()) {
            placeholderRef.current?.classList.remove('hidden');
        }
    };

    const hidePlaceholderIfEditorFilled = () => {
        const value = editorRef.current?.getControl().getValue();
        if (value && value.length > 0) {
            placeholderRef.current?.classList.add('hidden');
        }
    };

    const handlePin = () => {
        if (editorRef.current) {
            editorRef.current.getControl().getModel()?.applyEdits([{
                range: {
                    startLineNumber: 1,
                    startColumn: 1,
                    endLineNumber: 1,
                    endColumn: 1
                },
                text: '@',
            }]);
            editorRef.current.getControl().setPosition({ lineNumber: 1, column: 2 });
            editorRef.current.getControl().getAction('editor.action.triggerSuggest')?.run();
        }
    };

    const leftOptions = [
        ...(props.showContext
            ? [{
                title: nls.localize('theia/ai/chat-ui/attachToContext', 'Attach elements to context'),
                handler: () => props.onAddContextElement(),
                className: 'codicon-attach',
                disabled: !props.isEnabled
            }]
            : []),
        ...(props.showPinnedAgent
            ? [{
                title: props.pinnedAgent ? nls.localize('theia/ai/chat-ui/unpinAgent', 'Unpin Agent') : nls.localizeByDefault('Agent'),
                handler: props.pinnedAgent ? props.onUnpin : handlePin,
                className: 'codicon-mention',
                disabled: !props.isEnabled,
                text: {
                    align: 'right',
                    content: props.pinnedAgent && props.pinnedAgent.name
                },
            }]
            : []),
    ] as Option[];

    let rightOptions: Option[] = [];
    const { currentRequest: latestRequest, isEditing, pending, onResponseChanged } = props;
    React.useEffect(() => {
        if (!latestRequest) {
            return;
        }
        const disposable = latestRequest.response.onDidChange(onResponseChanged);
        return () => disposable.dispose();
    }, [latestRequest, onResponseChanged]);
    if (isEditing) {
        rightOptions = [{
            title: nls.localize('theia/ai/chat-ui/send', 'Send (Enter)'),
            handler: () => {
                if (props.isEnabled) {
                    submit(editorRef.current?.document.textEditorModel.getValue() || '');
                }
            },
            className: 'codicon-send',
            disabled: (isInputEmpty && !shouldUseTaskPlaceholder) || !props.isEnabled
        }];
    } else if (pending) {
        rightOptions = [{
            title: nls.localize('theia/ai/chat-ui/cancel', 'Cancel (Esc)'),
            handler: () => {
                if (latestRequest) {
                    props.onCancel(latestRequest);
                }
            },
            className: 'codicon-stop-circle'
        }];
    } else {
        rightOptions = [{
            title: nls.localize('theia/ai/chat-ui/send', 'Send (Enter)'),
            handler: () => {
                if (props.isEnabled) {
                    submit(editorRef.current?.document.textEditorModel.getValue() || '');
                }
            },
            className: 'codicon-send',
            disabled: (isInputEmpty && !shouldUseTaskPlaceholder) || !props.isEnabled
        }];
    }

    const contextUI = buildContextUI(props.context, props.labelProvider, props.onDeleteContextElement, props.onOpenContextElement, props.fileValidationState);

    // Show mode selector if agent has multiple modes
    const showModeSelector = (props.modeSelectorProps.receivingAgentModes?.length ?? 0) > 1;

    // Token usage computation (cheap pure function walking the model's request list)
    const totalTokens = props.tokenUsageEnabled ? computeSessionTokenUsage(props.chatModel) : 0;
    const showTokenUsage = props.tokenUsageEnabled && totalTokens > 0;
    const tokenColorClass = showTokenUsage ? getUsageColorClass(totalTokens, props.tokenUsageWarningThreshold, props.contextWindowSize) : '';
    const tokenIsWarningOrError = tokenColorClass === 'token-usage-yellow' || tokenColorClass === 'token-usage-red';
    const tokenTooltip = showTokenUsage
        ? buildBarTooltip(getLatestTokenUsage(props.chatModel), totalTokens, props.tokenUsageWarningThreshold, props.contextWindowSize, props.chatModel)
        : undefined;

    return (
        <div className="theia-ChatInput" data-ai-disabled={!props.isEnabled} onDragOver={props.onDragOver} onDrop={props.onDrop} ref={containerRef}>
            {props.showSuggestions !== false && <ChatInputAgentSuggestions suggestions={props.suggestions} opener={props.openerService} />}
            {props.showChangeSet && changeSetUI?.elements &&
                <ChangeSetBox changeSet={changeSetUI} />
            }
            <div className={`theia-ChatInput-Editor-Box${tokenIsWarningOrError ? ` token-usage-border-${tokenColorClass}` : ''}`}>
                {props.showCapabilities !== false && (
                    <CapabilitiesBar
                        isOpen={props.capabilitiesProps.isOpen}
                        capabilities={props.capabilitiesProps.capabilities}
                        overrides={props.capabilitiesProps.overrides}
                        onCapabilityChange={props.capabilitiesProps.onCapabilityChange}
                        genericCapabilities={props.genericCapabilitiesProps.genericCapabilities}
                        onGenericCapabilityChange={props.genericCapabilitiesProps.onGenericCapabilityChange}
                        onResetGenericCapabilities={props.genericCapabilitiesProps.onResetGenericCapabilities}
                        availableCapabilities={props.genericCapabilitiesProps.availableCapabilities}
                        disabledCapabilities={props.genericCapabilitiesProps.disabledCapabilities}
                        serverTools={props.genericCapabilitiesProps.serverTools}
                        disabled={!props.isEnabled}
                        hoverService={props.hoverService}
                        hasUnsavedChanges={props.capabilitiesProps.hasUnsavedChanges}
                        onSaveToSettings={props.capabilitiesProps.onSaveToSettings}
                    />
                )}
                <div className='theia-ChatInput-Editor' ref={editorContainerRef} onKeyDown={onKeyDown} onFocus={handleInputFocus} onBlur={handleInputBlur}>
                    <div ref={placeholderRef} className='theia-ChatInput-Editor-Placeholder'>{placeholderText}</div>
                </div>
                {props.context && props.context.length > 0 &&
                    <ChatContext context={contextUI.context} />
                }
                <ChatInputOptions
                    leftOptions={leftOptions}
                    rightOptions={rightOptions}
                    isEnabled={props.isEnabled}
                    hoverService={props.hoverService}
                    tokenUsage={showTokenUsage ? {
                        percent: Math.min((totalTokens / props.contextWindowSize) * 100, 100),
                        colorClass: tokenColorClass,
                        tooltip: tokenTooltip,
                    } : undefined}
                    modeSelectorProps={{
                        show: showModeSelector,
                        modes: props.modeSelectorProps.receivingAgentModes,
                        currentMode: props.modeSelectorProps.currentMode,
                        onModeChange: props.modeSelectorProps.onModeChange,
                        keybindingHint: props.modeSelectorProps.keybindingHint,
                    }}
                    reasoningSelectorProps={{
                        show: !!props.reasoningSelectorProps.reasoningSupport,
                        reasoningSupport: props.reasoningSelectorProps.reasoningSupport,
                        currentLevel: props.reasoningSelectorProps.currentLevel,
                        onReasoningChange: props.reasoningSelectorProps.onReasoningChange,
                    }}
                    modelSelectorProps={props.modelSelectorProps}
                    capabilitiesToggle={{
                        show: props.showCapabilities !== false,
                        isOpen: props.capabilitiesProps.isOpen,
                        hasActiveSelections: props.capabilitiesProps.overrides.size > 0
                            || GenericCapabilitySelections.hasSelections(props.genericCapabilitiesProps.genericCapabilities)
                            || Object.values(props.genericCapabilitiesProps.serverToolSelections).some(ids => ids.length > 0),
                        hasUnsavedChanges: props.capabilitiesProps.hasUnsavedChanges,
                        onToggle: props.capabilitiesProps.onToggle,
                        keybindingHint: props.capabilitiesProps.keybindingHint,
                    }}
                />
            </div>
        </div>
    );
};

/**
 * Returns an onMouseEnter handler that shows a hover tooltip via HoverService.
 */
function hoverHandler(hoverService: HoverService, content: string | MarkdownString, position: 'top' | 'bottom' = 'bottom'): (e: React.MouseEvent) => void {
    return (e: React.MouseEvent) => {
        hoverService.requestHover({
            content,
            target: e.currentTarget as HTMLElement,
            position
        });
    };
}

interface ChatInputOptionsProps {
    leftOptions: Option[];
    rightOptions: Option[];
    isEnabled?: boolean;
    hoverService: HoverService;
    tokenUsage?: {
        percent: number;
        colorClass: string;
        tooltip?: MarkdownString;
    };
    modeSelectorProps: {
        show: boolean;
        modes?: ChatMode[];
        currentMode?: string;
        onModeChange: (mode: string) => void;
        keybindingHint?: string;
    };
    reasoningSelectorProps: {
        show: boolean;
        reasoningSupport?: ReasoningSupport;
        currentLevel?: ReasoningLevel;
        onReasoningChange: (level: ReasoningLevel) => void;
    };
    modelSelectorProps: ModelSelectorWidgetProps;
    capabilitiesToggle: {
        show: boolean;
        isOpen: boolean;
        hasActiveSelections: boolean;
        hasUnsavedChanges: boolean;
        onToggle: () => void;
        keybindingHint?: string;
    };
}

const ChatInputOptions: React.FunctionComponent<ChatInputOptionsProps> = ({
    leftOptions,
    rightOptions,
    isEnabled,
    hoverService,
    tokenUsage,
    modeSelectorProps,
    reasoningSelectorProps,
    modelSelectorProps,
    capabilitiesToggle
}) => {
    const capabilitiesLabel = nls.localize('theia/ai/chat-ui/toggleCapabilitiesConfig', 'Toggle Capabilities Configuration');
    const capabilitiesTitle = capabilitiesToggle.keybindingHint
        ? `${capabilitiesLabel} (${capabilitiesToggle.keybindingHint})`
        : capabilitiesLabel;

    return (
        // Right options are rendered first in DOM for tab order (send button first when enabled)
        // CSS order property positions them visually (left on left, right on right)
        <div className="theia-ChatInputOptions">
            <div className="theia-ChatInputOptions-right">
                {tokenUsage && (
                    <span
                        className={`token-usage-badge ${tokenUsage.colorClass}`}
                        {...(tokenUsage.tooltip && { onMouseEnter: hoverHandler(hoverService, tokenUsage.tooltip, 'top') })}
                    >
                        <span
                            className='token-usage-ring'
                            style={{
                                background: `conic-gradient(var(--token-usage-fill) ${tokenUsage.percent}%, var(--token-usage-track) ${tokenUsage.percent}%)`
                            }}
                        >
                            <span className='token-usage-ring-inner' />
                        </span>
                    </span>
                )}
                {rightOptions.map((option, index) => (
                    <span
                        key={index}
                        className={`option${option.disabled ? ' disabled' : ''}${option.text?.align === 'right' ? ' reverse' : ''}`}
                        aria-label={option.title}
                        role='button'
                        tabIndex={option.disabled ? -1 : 0}
                        onClick={option.handler}
                        onMouseEnter={hoverHandler(hoverService, option.title)}
                        onKeyDown={e => {
                            if (e.key === 'Enter' || e.key === ' ') {
                                e.preventDefault();
                                option.handler();
                            }
                        }}
                    >
                        <span>{option.text?.content}</span>
                        <span className={`codicon ${option.className}`} />
                    </span>
                ))}
            </div>
            <div className="theia-ChatInputOptions-left">
                {leftOptions.map((option, index) => (
                    <span
                        key={index}
                        className={`option${option.disabled ? ' disabled' : ''}${option.text?.align === 'right' ? ' reverse' : ''}`}
                        aria-label={option.title}
                        role='button'
                        tabIndex={option.disabled ? -1 : 0}
                        onClick={option.handler}
                        onMouseEnter={hoverHandler(hoverService, option.title)}
                        onKeyDown={e => {
                            if (e.key === 'Enter' || e.key === ' ') {
                                e.preventDefault();
                                option.handler();
                            }
                        }}
                    >
                        <span>{option.text?.content}</span>
                        <span className={`codicon ${option.className}`} />
                    </span>
                ))}
                {modeSelectorProps.show && modeSelectorProps.modes && (
                    <ChatModeSelector
                        modes={modeSelectorProps.modes}
                        currentMode={modeSelectorProps.currentMode}
                        onModeChange={modeSelectorProps.onModeChange}
                        disabled={!isEnabled}
                        keybindingHint={modeSelectorProps.keybindingHint}
                        hoverService={hoverService}
                    />
                )}
                {capabilitiesToggle.show && (
                    <span
                        className={`option${capabilitiesToggle.isOpen ? ' toggled' : ''}`}
                        aria-label={capabilitiesLabel}
                        aria-expanded={capabilitiesToggle.isOpen}
                        aria-pressed={capabilitiesToggle.isOpen}
                        role='button'
                        tabIndex={0}
                        onClick={capabilitiesToggle.onToggle}
                        onMouseEnter={hoverHandler(hoverService, capabilitiesTitle)}
                        onKeyDown={e => {
                            if (e.key === 'Enter' || e.key === ' ') {
                                e.preventDefault();
                                capabilitiesToggle.onToggle();
                            }
                        }}
                    >
                        <span className="codicon codicon-tools" />
                        {capabilitiesToggle.hasUnsavedChanges && (
                            <span className="theia-capabilities-unsaved-indicator" />
                        )}
                    </span>
                )}
                {reasoningSelectorProps.show && reasoningSelectorProps.reasoningSupport && (
                    <ReasoningSelector
                        reasoningSupport={reasoningSelectorProps.reasoningSupport}
                        currentLevel={reasoningSelectorProps.currentLevel}
                        onReasoningChange={reasoningSelectorProps.onReasoningChange}
                        disabled={!isEnabled}
                        hoverService={hoverService}
                    />
                )}
                {(modelSelectorProps.models.length > 0 || modelSelectorProps.currentModelId) && (
                    <ChatModelSelector
                        models={modelSelectorProps.models}
                        currentModelId={modelSelectorProps.currentModelId}
                        defaultLabel={modelSelectorProps.defaultLabel}
                        onModelChange={modelSelectorProps.onModelChange}
                        disabled={!isEnabled}
                        hoverService={hoverService}
                    />
                )}
            </div>
        </div>
    );
};

interface ChatModeSelectorProps {
    modes: ChatMode[];
    currentMode?: string;
    onModeChange: (mode: string) => void;
    disabled?: boolean;
    keybindingHint?: string;
    hoverService: HoverService;
}

/**
 * Props for the CapabilitiesBar component.
 */
interface CapabilitiesBarProps {
    isOpen: boolean;
    capabilities: ParsedCapability[];
    overrides: Map<string, boolean>;
    onCapabilityChange: (fragmentId: string, enabled: boolean) => void;
    genericCapabilities: GenericCapabilitySelections;
    onGenericCapabilityChange: (type: keyof GenericCapabilitySelections, ids: string[]) => void;
    onResetGenericCapabilities: () => void;
    availableCapabilities: AvailableGenericCapabilities;
    disabledCapabilities: GenericCapabilitySelections;
    serverTools?: ServerToolsSection;
    disabled?: boolean;
    hoverService: HoverService;
    hasUnsavedChanges: boolean;
    onSaveToSettings: () => void;
}

/**
 * Combined capabilities bar that shows:
 * - Collapsed state: horizontal scrollable row of capability chips
 * - Expanded state: full panel with capabilities and generic capabilities
 */
const CapabilitiesBar: React.FunctionComponent<CapabilitiesBarProps> = ({
    isOpen,
    capabilities,
    overrides,
    onCapabilityChange,
    genericCapabilities,
    onGenericCapabilityChange,
    onResetGenericCapabilities,
    availableCapabilities,
    disabledCapabilities,
    serverTools,
    disabled,
    hoverService,
    hasUnsavedChanges,
    onSaveToSettings
}) => {
    if (isOpen) {
        // Expanded state: full panel with save button
        const hasCapabilities = capabilities.length > 0;
        const saveLabel = nls.localizeByDefault('Save');
        const saveTitle = nls.localize('theia/ai/chat-ui/saveCurrentSelectionsToSettings', 'Save capability settings');
        return (
            <div className="theia-ChatInput-CapabilitiesPanel">
                {hasCapabilities && (
                    <>
                        <div className="theia-ChatInput-CapabilitiesPanel-Left">
                            <CapabilityChipsRow
                                capabilities={capabilities}
                                overrides={overrides}
                                onCapabilityChange={onCapabilityChange}
                                disabled={disabled}
                                hoverService={hoverService} />
                        </div>
                        <div className="theia-ChatInput-CapabilitiesPanel-Divider" />
                    </>
                )}
                <div className="theia-ChatInput-CapabilitiesPanel-Right">
                    <GenericCapabilitiesSection
                        genericCapabilities={genericCapabilities}
                        onGenericCapabilityChange={onGenericCapabilityChange}
                        onResetGenericCapabilities={onResetGenericCapabilities}
                        availableCapabilities={availableCapabilities}
                        disabledCapabilities={disabledCapabilities}
                        serverTools={serverTools}
                        disabled={disabled}
                        hoverService={hoverService} />
                </div>
                <div className="theia-ChatInput-CapabilitiesPanel-SaveButton">
                    <button
                        className="theia-button"
                        disabled={!hasUnsavedChanges || disabled}
                        title={saveTitle}
                        onClick={onSaveToSettings}
                    >
                        {saveLabel}
                    </button>
                </div>
            </div>
        );
    }

    // Collapsed state: horizontal scrollable row of capability chips
    if (capabilities.length === 0) {
        return undefined;
    }
    return (
        <div className="theia-capabilities-collapsed-bar">
            <div className="theia-capabilities-collapsed-scrollbar">
                {capabilities.map((capability, index) => {
                    const isChecked = overrides.get(capability.fragmentId) ?? capability.defaultEnabled;
                    return (
                        <CapabilityChip
                            key={capability.fragmentId}
                            fragmentId={capability.fragmentId}
                            name={capability.name}
                            description={capability.description}
                            checked={isChecked}
                            disabled={disabled}
                            tabIndex={disabled ? -1 : 0}
                            onToggle={onCapabilityChange}
                            onFocus={() => { }}
                            hoverService={hoverService}
                        />
                    );
                })}
            </div>
        </div>
    );
};

const ChatModeSelector: React.FunctionComponent<ChatModeSelectorProps> = React.memo(({ modes, currentMode, onModeChange, disabled, keybindingHint, hoverService }) => {
    const options: SelectOption[] = React.useMemo(
        () => modes.map(mode => ({ value: mode.id, label: mode.name })),
        [modes]
    );

    const handleChange = React.useCallback(
        (option: SelectOption) => {
            if (option.value) {
                onModeChange(option.value);
            }
        },
        [onModeChange]
    );

    const label = nls.localize('theia/ai/chat-ui/switchAgentMode', 'Switch Agent Mode');
    const title = keybindingHint ? `${label} (${keybindingHint})` : label;

    return (
        <span onMouseEnter={hoverHandler(hoverService, title)}>
            <SelectComponent
                className={`theia-ChatInput-ModeSelector${disabled ? ' disabled' : ''}`}
                options={options}
                defaultValue={currentMode ?? modes[0]?.id ?? ''}
                onChange={handleChange}
            />
        </span>
    );
});

interface ChatModelSelectorProps {
    models: LanguageModel[];
    currentModelId?: string;
    defaultLabel: string;
    onModelChange: (modelId: string | undefined) => void;
    disabled?: boolean;
    hoverService: HoverService;
}

/**
 * Per-session model selector. The first option ("Default") reverts to the agent's configured
 * model that new sessions use; picking any other model overrides it for the current session only.
 */
const ChatModelSelector: React.FunctionComponent<ChatModelSelectorProps> = React.memo(({
    models, currentModelId, defaultLabel, onModelChange, disabled, hoverService
}) => {
    // Sentinel value for the "use the agent default" option (SelectComponent needs a non-empty value).
    const defaultValueId = '__default__';
    const isOverridden = !!currentModelId;
    // The override points at a model that is no longer ready/available. Guard on a loaded model list so
    // the override is not flagged as unavailable during the initial (still empty) load.
    const isUnavailable = isOverridden && models.length > 0
        && !models.some(model => model.id === currentModelId && model.status.status === 'ready');
    const options: SelectOption[] = React.useMemo(() => {
        const readyModels = models.filter(model => model.status.status === 'ready')
            // Stable, predictable order: the registry adds models as their async metadata resolves.
            .sort((left, right) => left.id.localeCompare(right.id));
        const opts: SelectOption[] = [
            {
                value: defaultValueId,
                label: nls.localizeByDefault('Default'),
                detail: defaultLabel
            },
            ...readyModels.map(model => ({
                value: model.id,
                label: model.id,
                detail: model.name && model.name !== model.id ? model.name : undefined
            }))
        ];
        // Keep the active override visible even if its model is no longer ready/available, so the
        // selector reflects the stored modelId instead of silently falling back to "Default". Once we
        // know it is unavailable, show it as a disabled (struck-through) entry that cannot be reselected.
        if (currentModelId && !opts.some(option => option.value === currentModelId)) {
            opts.push({
                value: currentModelId,
                label: currentModelId,
                detail: nls.localize('theia/ai/chat-ui/modelUnavailable', 'unavailable'),
                disabled: isUnavailable
            });
        }
        return opts;
    }, [models, defaultLabel, currentModelId, isUnavailable]);

    const handleChange = React.useCallback(
        (option: SelectOption) => onModelChange(!option.value || option.value === defaultValueId ? undefined : option.value),
        [onModelChange]
    );

    const title = isUnavailable
        ? nls.localize('theia/ai/chat-ui/sessionModelUnavailable', 'Model for this chat is unavailable ({0}); requests use the agent default', currentModelId!)
        : isOverridden
            ? nls.localize('theia/ai/chat-ui/sessionModel', 'Model for this chat')
            : nls.localize('theia/ai/chat-ui/sessionModelDefault', 'Model for this chat ({0})', defaultLabel);

    return (
        <span className='theia-ChatInput-ModelSelector-container' onMouseEnter={hoverHandler(hoverService, title)}>
            <SelectComponent
                className={`theia-ChatInput-ModelSelector${isOverridden ? ' session-override' : ''}${disabled ? ' disabled' : ''}`}
                options={options}
                defaultValue={currentModelId ?? defaultValueId}
                onChange={handleChange}
            />
        </span>
    );
});

interface ReasoningSelectorProps {
    reasoningSupport: ReasoningSupport;
    currentLevel?: ReasoningLevel;
    onReasoningChange: (level: ReasoningLevel) => void;
    disabled?: boolean;
    hoverService: HoverService;
}

const reasoningLevelLabel = (level: ReasoningLevel): string => {
    switch (level) {
        case 'off': return nls.localizeByDefault('Off');
        case 'minimal': return nls.localizeByDefault('Minimal');
        case 'low': return nls.localizeByDefault('Low');
        case 'medium': return nls.localizeByDefault('Medium');
        case 'high': return nls.localizeByDefault('High');
        case 'auto': return nls.localizeByDefault('Auto');
    }
};

const ReasoningSelector: React.FunctionComponent<ReasoningSelectorProps> = React.memo(({
    reasoningSupport, currentLevel, onReasoningChange, disabled, hoverService
}) => {
    const options: SelectOption[] = React.useMemo(
        () => reasoningSupport.supportedLevels.map(level => ({ value: level, label: reasoningLevelLabel(level) })),
        [reasoningSupport]
    );

    const handleChange = React.useCallback(
        (option: SelectOption) => {
            if (option.value) {
                onReasoningChange(option.value as ReasoningLevel);
            }
        },
        [onReasoningChange]
    );

    const title = nls.localizeByDefault('Reasoning');
    const effectiveLevel = currentLevel ?? reasoningSupport.defaultLevel ?? reasoningSupport.supportedLevels[0] ?? 'off';

    return (
        <span onMouseEnter={hoverHandler(hoverService, title)}>
            <SelectComponent
                className={`theia-ChatInput-ReasoningSelector reasoning-level-${effectiveLevel}${disabled ? ' disabled' : ''}`}
                options={options}
                defaultValue={effectiveLevel}
                onChange={handleChange}
            />
        </span>
    );
});

const noPropagation = (handler: () => void) => (e: React.MouseEvent) => {
    handler();
    e.stopPropagation();
};

const buildChangeSetUI = (
    changeSet: ChangeSet,
    labelProvider: LabelProvider,
    decoratorService: ChangeSetDecoratorService,
    actions: ChangeSetActionRenderer[],
    onDeleteChangeSet: () => void,
    onDeleteChangeSetElement: (uri: URI) => void
): ChangeSetUI | undefined => {
    const elements = changeSet.getElements();
    return elements.length ? ({
        title: changeSet.title,
        changeSet,
        deleteChangeSet: onDeleteChangeSet,
        elements: changeSet.getElements().map(element => toUiElement(element, onDeleteChangeSetElement, labelProvider, decoratorService)),
        actions
    }) : undefined;
};

interface ChangeSetUIElement {
    name: string;
    uri: string;
    iconClass: string;
    nameClass: string;
    additionalInfo: string;
    additionalInfoSuffixIcon?: string[];
    open?: () => void;
    openChange?: () => void;
    apply?: () => void;
    revert?: () => void;
    delete: () => void;
}

interface ChangeSetUI {
    changeSet: ChangeSet;
    title: string;
    deleteChangeSet: () => void;
    elements: ChangeSetUIElement[];
    actions: ChangeSetActionRenderer[];
}

/** Memo because the parent element rerenders on every key press in the chat widget. */
const ChangeSetBox: React.FunctionComponent<{ changeSet: ChangeSetUI }> = React.memo(({ changeSet: { changeSet, title, deleteChangeSet, elements, actions } }) => {
    const [isCollapsed, setIsCollapsed] = React.useState(false);

    const toggleCollapse = React.useCallback(() => {
        setIsCollapsed(prev => !prev);
    }, []);

    const handleToggleKeyDown = React.useCallback(
        (e: React.KeyboardEvent) => {
            if (e.key === 'Enter' || e.key === ' ') {
                e.preventDefault();
                toggleCollapse();
            }
        },
        [toggleCollapse]
    );

    return (
        <div className='theia-ChatInput-ChangeSet-Box'>
            <div className='theia-ChatInput-ChangeSet-Header'>
                <div className='theia-ChatInput-ChangeSet-Header-Title'>
                    <span
                        className={`codicon ${isCollapsed ? 'codicon-chevron-right' : 'codicon-chevron-down'} theia-ChatInput-ChangeSet-Toggle`}
                        onClick={toggleCollapse}
                        onKeyDown={handleToggleKeyDown}
                        role='button'
                        tabIndex={0}
                        aria-expanded={!isCollapsed}
                        aria-label={
                            isCollapsed
                                ? nls.localize('theia/ai/chat-ui/expandChangeSet', 'Expand Change Set')
                                : nls.localize('theia/ai/chat-ui/collapseChangeSet', 'Collapse Change Set')
                        }
                        title={
                            isCollapsed
                                ? nls.localize('theia/ai/chat-ui/expandChangeSet', 'Expand Change Set')
                                : nls.localize('theia/ai/chat-ui/collapseChangeSet', 'Collapse Change Set')
                        }
                    />
                    <h3>{title}</h3>
                </div>
                <div className='theia-ChatInput-ChangeSet-Header-Actions'>
                    {actions.map(action => (
                        <div key={action.id} className='theia-changeSet-Action'>
                            {action.render(changeSet)}
                        </div>
                    ))}
                    <span
                        className='codicon codicon-close action'
                        title={nls.localize('theia/ai/chat-ui/deleteChangeSet', 'Delete Change Set')}
                        onClick={() => deleteChangeSet()}
                    />
                </div>
            </div>
            <div className={`theia-ChatInput-ChangeSet-List ${isCollapsed ? 'collapsed' : 'expanded'}`}>
                <ul>
                    {elements.map(element => <ChangeSetElement key={element.uri} {...element} />)}
                </ul>
            </div>
        </div>
    );
});

function toUiElement(element: ChangeSetElement,
    onDeleteChangeSetElement: (uri: URI) => void,
    labelProvider: LabelProvider,
    decoratorService: ChangeSetDecoratorService
): ChangeSetUIElement {
    return ({
        open: element.open?.bind(element),
        uri: element.uri.toString(),
        iconClass: element.icon ?? labelProvider.getIcon(element.uri) ?? labelProvider.fileIcon,
        nameClass: `${element.type} ${element.state}`,
        name: element.name ?? labelProvider.getName(element.uri),
        additionalInfo: element.additionalInfo ?? labelProvider.getDetails(element.uri),
        additionalInfoSuffixIcon: decoratorService.getAdditionalInfoSuffixIcon(element),
        openChange: element?.openChange?.bind(element),
        apply: element.state !== 'applied' ? element?.apply?.bind(element) : undefined,
        revert: element.state === 'applied' || element.state === 'stale' ? element?.revert?.bind(element) : undefined,
        delete: () => onDeleteChangeSetElement(element.uri)
    } satisfies ChangeSetUIElement);
}

const ChangeSetElement: React.FC<ChangeSetUIElement> = element => (
    <li title={nls.localize('theia/ai/chat-ui/openDiff', 'Open Diff')} onClick={() => element.openChange?.()}>
        <div className={`theia-ChatInput-ChangeSet-Icon ${element.iconClass}`}>
        </div>
        <div className='theia-ChatInput-ChangeSet-labelParts'>
            <span className={`theia-ChatInput-ChangeSet-title ${element.nameClass}`}>
                {element.name}
            </span>
            <div className='theia-ChatInput-ChangeSet-additionalInfo'>
                {element.additionalInfo && <span>{element.additionalInfo}</span>}
                {element.additionalInfoSuffixIcon
                    && <div className={`theia-ChatInput-ChangeSet-AdditionalInfo-SuffixIcon ${element.additionalInfoSuffixIcon.join(' ')}`}></div>}
            </div>
        </div>
        <div className='theia-ChatInput-ChangeSet-Actions'>
            {element.open && (
                <span
                    className='codicon codicon-file action'
                    title={nls.localize('theia/ai/chat-ui/openOriginalFile', 'Open Original File')}
                    onClick={noPropagation(() => element.open!())}
                />)}
            {element.revert && (
                <span
                    className='codicon codicon-discard action'
                    title={nls.localizeByDefault('Revert')}
                    onClick={noPropagation(() => element.revert!())}
                />)}
            {element.apply && (
                <span
                    className='codicon codicon-check action'
                    title={nls.localizeByDefault('Apply')}
                    onClick={noPropagation(() => element.apply!())}
                />)}
            <span className='codicon codicon-close action' title={nls.localizeByDefault('Delete')} onClick={noPropagation(() => element.delete())} />
        </div>
    </li>
);

interface Option {
    title: string;
    handler: () => void;
    className: string;
    disabled?: boolean;
    text?: {
        align?: 'left' | 'right';
        content: string;
    };
}

function buildContextUI(
    context: readonly AIVariableResolutionRequest[] | undefined,
    labelProvider: LabelProvider,
    onDeleteContextElement: (index: number) => void,
    onOpen: OpenContextElement,
    fileValidationState: Map<string, FileValidationResult>
): ChatContextUI {
    if (!context) {
        return { context: [] };
    }
    return {
        context: context.map((element, index) => {
            // Check if this is an invalid file
            let className: string | undefined;
            let validationMessage: string | undefined;
            if (element.variable.name === 'file' && element.arg) {
                // Use the path directly as the key (same as storage)
                const validationResult = fileValidationState.get(element.arg);
                if (validationResult) {
                    if (validationResult.state === FileValidationState.INVALID_NOT_FOUND) {
                        className = 'invalid-file';
                        validationMessage = validationResult.message;
                    }
                }
            }
            return {
                variable: element,
                name: labelProvider.getName(element),
                iconClass: labelProvider.getIcon(element),
                nameClass: element.variable.name,
                className,
                validationMessage,
                additionalInfo: labelProvider.getDetails(element),
                details: labelProvider.getLongName(element),
                delete: () => onDeleteContextElement(index),
                open: () => onOpen(element)
            };
        })
    };
}

interface ChatContextUI {
    context: {
        variable: AIVariableResolutionRequest,
        name: string;
        iconClass: string;
        nameClass: string;
        className?: string;
        validationMessage?: string;
        additionalInfo?: string;
        details?: string;
        delete: () => void;
        open?: () => void;
    }[];
}

const ChatContext: React.FunctionComponent<ChatContextUI> = ({ context }) => (
    <div className="theia-ChatInput-ChatContext">
        <ul>
            {context.map((element, index) => {
                if (ImageContextVariable.isImageContextRequest(element.variable)) {
                    const variable = ImageContextVariable.parseRequest(element.variable);
                    const displayName = variable?.name ?? variable?.wsRelativePath?.split('/').pop() ?? element.name;
                    const title = variable?.name ?? variable?.wsRelativePath ?? element.details ?? element.name;
                    // Check if we have the actual image data (pre-processed) or just a path reference
                    const hasImageData = variable && ImageContextVariable.isResolved(variable);

                    return <li key={index} className="theia-ChatInput-ChatContext-Element theia-ChatInput-ImageContext-Element"
                        title={title} onClick={() => element.open?.()}>
                        <div className="theia-ChatInput-ChatContext-Row">
                            <div className={`theia-ChatInput-ChatContext-Icon ${element.iconClass}`} />
                            <div className="theia-ChatInput-ChatContext-labelParts">
                                <span className={`theia-ChatInput-ChatContext-title ${element.nameClass}`}>
                                    {displayName}
                                </span>
                                <span className='theia-ChatInput-ChatContext-additionalInfo'>
                                    {element.additionalInfo}
                                </span>
                            </div>
                            <span className="codicon codicon-close action" title={nls.localizeByDefault('Delete')} onClick={e => { e.stopPropagation(); element.delete(); }} />
                        </div>
                        {hasImageData && <div className="theia-ChatInput-ChatContext-ImageRow">
                            <div className='theia-ChatInput-ImagePreview-Item'>
                                <img src={`data:${variable.mimeType};base64,${variable.data}`} alt={variable.name ?? displayName} />
                            </div>
                        </div>}
                    </li>;
                }
                const isWarning = element.className === 'warning-file';
                const isInvalid = element.className === 'invalid-file';
                const tooltipTitle = element.validationMessage ?? element.details;
                return <li key={index} className={`theia-ChatInput-ChatContext-Element ${element.className || ''}`} title={tooltipTitle} onClick={() => element.open?.()}>
                    <div className="theia-ChatInput-ChatContext-Row">
                        {isWarning && <span className="codicon codicon-warning warning-file-icon" />}
                        {isInvalid && <span className="codicon codicon-error invalid-file-icon" />}
                        <div className={`theia-ChatInput-ChatContext-Icon ${element.iconClass}`} />
                        <div className="theia-ChatInput-ChatContext-labelParts">
                            <span className={`theia-ChatInput-ChatContext-title ${element.nameClass}`}>
                                {element.name}
                            </span>
                            <span className='theia-ChatInput-ChatContext-additionalInfo'>
                                {element.additionalInfo}
                            </span>
                        </div>
                        <span className="codicon codicon-close action" title={nls.localizeByDefault('Delete')} onClick={e => { e.stopPropagation(); element.delete(); }} />
                    </div>
                </li>;
            })}
        </ul>
    </div>
);
