From a690212e2128b6d931564225cca17cb6957549ae Mon Sep 17 00:00:00 2001 From: Emma <121360998+emxs1@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:57:59 -0700 Subject: [PATCH 1/2] add more fun working messages --- .../chatThinkingContentPart.ts | 5521 +++++++++-------- 1 file changed, 2766 insertions(+), 2755 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index e4652dd192dd73..6c38e94f28c870 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -1,2755 +1,2766 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, addDisposableListener, clearNode, DisposableResizeObserver, EventHelper, EventType, getWindow, hide, isHTMLElement, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js'; -import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; -import { Button } from '../../../../../../base/browser/ui/button/button.js'; -import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; -import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; -import { IChatExternalEdit, IChatMarkdownContent, IChatTerminalToolInvocationData, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; -import { IChatContentPart, IChatContentPartDiffData, IChatContentPartDiffResource, IChatContentPartRenderContext } from './chatContentParts.js'; -import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { ChatConfiguration, ThinkingDisplayMode } from '../../../common/constants.js'; -import { ChatTreeItem } from '../../chat.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; -import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../../base/common/htmlContent.js'; -import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; -import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; -import { basename, getComparisonKey } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ChatThinkingStyleContentPart, createThinkingIcon } from './chatThinkingStyleContentPart.js'; -export { createThinkingIcon }; -import { renderFileWidgets } from './chatInlineAnchorWidget.js'; -import { localize } from '../../../../../../nls.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun, IReader } from '../../../../../../base/common/observable.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; -import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; -import './media/chatThinkingContent.css'; -import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { getCompactCodicon } from '../../chatIcons.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; -import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; -import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; -import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js'; -import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; - - -// Context key id mirrored from `vs/sessions/common/contextkeys` (`IsPhoneLayoutContext`). -// Inlined as a string because `vs/workbench` must not import from `vs/sessions`. -const SESSIONS_IS_PHONE_LAYOUT_KEY = 'sessionsIsPhoneLayout'; - -/** - * Read-only chats and phone layouts use collapsed preview regardless of the configured thinking style. - */ -export function getEffectiveThinkingDisplayMode(configurationService: IConfigurationService, contextKeyService: IContextKeyService, readOnly = false): ThinkingDisplayMode { - if (readOnly || contextKeyService.getContextKeyValue(SESSIONS_IS_PHONE_LAYOUT_KEY) === true) { - return ThinkingDisplayMode.CollapsedPreview; - } - return configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; -} - -function extractTextFromPart(content: IChatThinkingPart): string { - const raw = Array.isArray(content.value) ? content.value.join('') : (content.value || ''); - return raw.trim(); -} - -function isEditToolId(toolId: string): boolean { - const lowerToolId = toolId.toLowerCase(); - return lowerToolId.includes('edit') || - lowerToolId.includes('create') || - lowerToolId.includes('replace') || - lowerToolId.includes('patch'); -} - -/** - * Returns true for edit tools whose generic display name should be replaced - * with "Editing files" while streaming (e.g. replace, multi-replace, patch, insertEdit). - * Excludes create and notebook tools which already have good labels. - */ -function isGenericEditToolId(toolId: string): boolean { - const lowerToolId = toolId.toLowerCase(); - if (lowerToolId.includes('create') || lowerToolId.includes('notebook')) { - return false; - } - return lowerToolId.includes('replace') || - lowerToolId.includes('patch') || - lowerToolId.includes('insertedit') || - lowerToolId.includes('insert_edit') || - lowerToolId.includes('editfile'); -} - -function isProblemsToolId(toolId: string | undefined): boolean { - switch (toolId?.toLowerCase()) { - case 'problems': - case 'get_errors': - case 'copilot_geterrors': - return true; - default: - return false; - } -} - -function isNoProblemsFoundResult(toolId: string | undefined, resultText: string | undefined): boolean { - return isProblemsToolId(toolId) && resultText?.toLowerCase().includes('no problems found') === true; -} - -export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon, resultText?: string): ThemeIcon { - if (isNoProblemsFoundResult(toolId, resultText)) { - return Codicon.search; - } - - if (registeredIcon) { - return registeredIcon; - } - - const lowerToolId = toolId.toLowerCase(); - - if (lowerToolId.includes('comment')) { - return Codicon.comment; - } - - if ( - lowerToolId.includes('search') || - lowerToolId.includes('grep') || - lowerToolId.includes('find') || - lowerToolId.includes('list') || - lowerToolId.includes('semantic') || - lowerToolId.includes('changes') || - lowerToolId.includes('codebase') || - lowerToolId.includes('checked') - ) { - return Codicon.search; - } - - if ( - lowerToolId.includes('read') || - lowerToolId.includes('get_file') || - lowerToolId.includes('problems') - ) { - return Codicon.book; - } - - if (isEditToolId(toolId)) { - return Codicon.pencil; - } - - if ( - lowerToolId.includes('terminal') - ) { - return Codicon.terminal; - } - - // default to generic tool icon - return Codicon.tools; -} - -function setThinkingIcon(iconElement: HTMLElement, icon: ThemeIcon): void { - iconElement.className = 'chat-thinking-icon'; - iconElement.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); -} - -function extractTitleFromThinkingContent(content: string): string | undefined { - const headerMatch = content.match(/^\*\*([^*]+)\*\*/); - return headerMatch ? headerMatch[1] : undefined; -} - -/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ -function isThinkingHeaderLine(line: string): boolean { - return /^\s*\*\*.+\*\*\s*$/.test(line); -} - -/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ -function stripStandaloneBold(text: string): string { - const trimmed = text.trim(); - if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { - return trimmed.slice(2, -2); - } - return text; -} - -/** - * Splits a reasoning-summary value into one markdown string per display row. - * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set - * and the value starts with a header, that header is dropped because it is - * surfaced as the collapsible title. Returns `undefined` unless the value has at - * least two header lines, so ordinary reasoning prose keeps single-block rendering. - */ -export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { - const sections: { isHeader: boolean; lines: string[] }[] = []; - for (const line of text.split('\n')) { - if (isThinkingHeaderLine(line)) { - sections.push({ isHeader: true, lines: [line] }); - } else if (sections.length === 0) { - sections.push({ isHeader: false, lines: [line] }); - } else { - sections[sections.length - 1].lines.push(line); - } - } - - if (sections.filter(section => section.isHeader).length < 2) { - return undefined; - } - - const dropFirst = dropLeadingHeader && sections[0].isHeader; - const rows: string[] = []; - sections.forEach((section, index) => { - const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; - const markdown = lines.join('\n').trim(); - if (markdown) { - rows.push(markdown); - } - }); - - return rows.length ? rows : undefined; -} - -type ChatThinkingTitle = string | IMarkdownString; - -function getThinkingTitleValue(title: ChatThinkingTitle): string { - return typeof title === 'string' ? title : title.value; -} - -function thinkingTitleEqual(first: ChatThinkingTitle, second: ChatThinkingTitle): boolean { - if (typeof first === 'string' || typeof second === 'string') { - return first === second; - } - return markdownStringEqual(first, second); -} - -/** - * Metadata passed to {@link ChatThinkingContentPart.appendItem} to drive - * title / icon extraction. The `kind` discriminates which payload is - * available; the thinking part inspects it to compute a label like - * "Edited foo.ts" without rendering the actual content itself (the - * factory provides the DOM). - */ -export type ChatThinkingItemMetadata = - | IChatToolInvocation - | IChatToolInvocationSerialized - | IChatMarkdownContent - | IChatExternalEdit; - -interface ILazyToolItem { - kind: 'tool'; - lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; - toolInvocationId?: string; - toolInvocationOrMarkdown?: ChatThinkingItemMetadata; - originalParent?: HTMLElement; - isHook?: boolean; -} - -interface ILazyThinkingItem { - kind: 'thinking'; - textContainer: HTMLElement; - content: IChatThinkingPart; -} - -type ILazyItem = ILazyToolItem | ILazyThinkingItem; -const THINKING_SCROLL_MAX_HEIGHT = 200; - -const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache'; -const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -const TITLE_CACHE_MAX_ENTRIES = 1000; - -const enum WorkingMessageCategory { - Thinking = 'thinking', - Terminal = 'terminal', - Tool = 'tool' -} - -export const defaultThinkingMessages = [ - localize('chat.thinking.thinking.1', 'Thinking'), - localize('chat.thinking.thinking.2', 'Reasoning'), - localize('chat.thinking.thinking.3', 'Considering'), - localize('chat.thinking.thinking.4', 'Analyzing'), - localize('chat.thinking.thinking.5', 'Evaluating'), - localize('chat.thinking.thinking.6', 'Working'), -]; - -const terminalMessages = [ - localize('chat.thinking.terminal.1', 'Executing'), - localize('chat.thinking.terminal.2', 'Running'), - localize('chat.thinking.terminal.3', 'Processing'), -]; - -const toolMessages = [ - localize('chat.thinking.tool.1', 'Processing'), - localize('chat.thinking.tool.2', 'Preparing'), - localize('chat.thinking.tool.3', 'Loading'), - localize('chat.thinking.tool.4', 'Analyzing'), - localize('chat.thinking.tool.5', 'Evaluating'), -]; - -/** Easter-egg loading messages, used ~1 in {@link FUN_WORKING_MESSAGE_RATE} picks. */ -const funWorkingMessages = [ - // Generic - localize('chat.working.fun.1', "Bribing the hamster"), - localize('chat.working.fun.2', "Reticulating splines"), - localize('chat.working.fun.3', "Untangling the spaghetti"), - localize('chat.working.fun.4', "Communing with the codebase"), - - // Minecraft - localize('chat.working.fun.minecraft.1', "Mining diamonds"), - - // Microsoft - localize('chat.working.fun.ms.1', "Summoning Clippy"), -]; - -const FUN_WORKING_MESSAGE_RATE = 50; - -type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; - -function getCustomThinkingPhrases(configurationService: IConfigurationService): { customPhrases: string[]; replaceDefaults: boolean } { - const config = configurationService.getValue(ChatConfiguration.ThinkingPhrases); - const customPhrases = Array.isArray(config?.phrases) - ? config.phrases - .filter((phrase): phrase is string => typeof phrase === 'string') - .map(phrase => phrase.trim()) - .filter(phrase => phrase.length > 0) - : []; - - return { - customPhrases, - replaceDefaults: config?.mode === 'replace' && customPhrases.length > 0, - }; -} - -/** Returns an easter-egg message ~1 in {@link FUN_WORKING_MESSAGE_RATE}, else `undefined`. */ -export function maybePickFunWorkingMessage(configurationService: IConfigurationService, random = Math.random): string | undefined { - if (getCustomThinkingPhrases(configurationService).replaceDefaults) { - return undefined; - } - - if (Math.floor(random() * FUN_WORKING_MESSAGE_RATE) === 0) { - return funWorkingMessages[Math.floor(random() * funWorkingMessages.length)]; - } - return undefined; -} - -/** - * Builds a phrase pool from defaults and user-configured custom phrases. - * In 'replace' mode, only custom phrases are used; in 'append' mode (default), - * custom phrases are added to the defaults. - */ -export function buildPhrasePool(defaults: string[], configurationService: IConfigurationService): string[] { - const { customPhrases, replaceDefaults } = getCustomThinkingPhrases(configurationService); - - if (customPhrases.length > 0) { - return replaceDefaults ? [...customPhrases] : [...defaults, ...customPhrases]; - } - return [...defaults]; -} - -export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implements IChatContentPart { - - private static _codeBlockRendererSync(_languageId: string, text: string, _raw?: string): HTMLElement { - const codeElement = $('code'); - codeElement.textContent = text; - return codeElement; - } - - public readonly codeblocks: undefined; - public readonly codeblocksPartId: undefined; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - private readonly _asyncRenderCallback = () => this._onDidChangeHeight.fire(); - - private id: string | undefined; - private content: IChatThinkingPart; - private currentThinkingValue: string; - private currentTitle: string; - private defaultTitle = localize('chat.thinking.header', 'Thinking'); - private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); - private textContainer!: HTMLElement; - private readonly _markdownResult = this._register(new MutableDisposable()); - private summaryRowItems: HTMLElement[] = []; - private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; - private summaryRowTexts: string[] = []; - private droppedSummaryHeader: string | undefined; - private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; - private wrapper!: HTMLElement; - private fixedScrollingMode: boolean = false; - private readonly thinkingDisplayMode: ThinkingDisplayMode; - private autoScrollEnabled: boolean = true; - private scrollableElement: DomScrollableElement | undefined; - private lastExtractedTitle: string | undefined; - private extractedTitles: string[] = []; - private toolInvocationCount: number = 0; - private appendedItemCount: number = 0; - private isActive: boolean = true; - private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; - private allThinkingParts: IChatThinkingPart[] = []; - private hookCount: number = 0; - private singleItemInfo: { element: HTMLElement; thinkingWrapper: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null; restoreToOriginalParent: boolean; toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized } | undefined; - private lazyItems: ILazyItem[] = []; - private hasExpandedOnce: boolean = false; - private workingSpinnerElement: HTMLElement | undefined; - private workingSpinnerLabel: HTMLElement | undefined; - private availableMessagesByCategory = new Map(); - private readonly toolWrappersByCallId = new Map(); - private readonly toolIconsByCallId = new Map(); - private readonly toolLabelsByCallId = new Map(); - private readonly toolDisposables = this._register(new DisposableMap()); - private readonly ownedToolParts = new Map(); - private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; - private pendingRemovalFlushDisposable: IDisposable | undefined; - private pendingScrollDisposable: IDisposable | undefined; - private wrapperResizeObserverDisposable: IDisposable | undefined; - private childResizeObserver: DisposableResizeObserver | undefined; - private isUpdatingDimensions: boolean = false; - private lastKnownContentHeight: number = 0; - private lastKnownScrollTop: number = 0; - private titleDetailContainer: HTMLElement | undefined; - private lastRenderedTitle: ChatThinkingTitle | undefined; - private collapsedTitleBeforeExpansion: ChatThinkingTitle | undefined; - private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; - private readonly _pendingExternalResources = new Map(); - private readonly _titleDetailRendered = this._register(new MutableDisposable()); - private readonly _pendingAppendRefresh = this._register(new MutableDisposable()); - private readonly diffDataByPartId = new Map(); - private _aggregatedDiff: IEditSessionDiffStats = { added: 0, removed: 0 }; - private readonly diffButtonStore = this._register(new DisposableStore()); - private diffButton: Button | undefined; - private containsReasoning: boolean; - private containsGroupedItems: boolean = false; - private reasoningDurationMs: number | undefined; - - get aggregatedDiff(): IEditSessionDiffStats { return this._aggregatedDiff; } - - private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { - const fun = maybePickFunWorkingMessage(this.configurationService); - if (fun) { - return fun; - } - - let pool = this.availableMessagesByCategory.get(category); - if (!pool || pool.length === 0) { - let defaults: string[]; - switch (category) { - case WorkingMessageCategory.Thinking: - defaults = defaultThinkingMessages; - break; - case WorkingMessageCategory.Terminal: - defaults = terminalMessages; - break; - case WorkingMessageCategory.Tool: - default: - defaults = toolMessages; - break; - } - - pool = buildPhrasePool(defaults, this.configurationService); - - this.availableMessagesByCategory.set(category, pool); - } - const index = Math.floor(Math.random() * pool.length); - return pool.splice(index, 1)[0]; - } - - constructor( - content: IChatThinkingPart, - context: IChatContentPartRenderContext, - private readonly chatContentMarkdownRenderer: IMarkdownRenderer, - private streamingCompleted: boolean, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @IHoverService hoverService: IHoverService, - @ITelemetryService telemetryService: ITelemetryService, - @IStorageService private readonly storageService: IStorageService, - @IContextKeyService contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService, - ) { - const initialText = extractTextFromPart(content); - const containsReasoning = initialText.trim().length > 0; - const extractedTitle = extractTitleFromThinkingContent(initialText) - ?? localize('chat.thinking.header.initial', 'Thinking'); - - super(extractedTitle, context, undefined, hoverService, configurationService, telemetryService); - - this.containsReasoning = containsReasoning; - this.reasoningDurationMs = content.reasoningDurationMs; - this.id = content.id; - this.content = content; - this.allThinkingParts.push(content); - const configuredMode = getEffectiveThinkingDisplayMode(this.configurationService, contextKeyService, context.readOnly); - this.thinkingDisplayMode = configuredMode; - - this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; - - this.currentTitle = extractedTitle; - if (extractedTitle !== this.defaultTitle) { - this.lastExtractedTitle = extractedTitle; - this.extractedTitles.push(extractedTitle); - } - this.currentThinkingValue = initialText; - this.trackDroppedSummaryHeader(initialText); - - if (initialText.trim()) { - this.appendedItemCount++; - } - - // Alert screen reader users that thinking has started - if (this.configurationService.getValue(AccessibilityWorkbenchSettingId.VerboseChatProgressUpdates)) { - alert(localize('chat.thinking.started', 'Thinking')); - } - - if (configuredMode === ThinkingDisplayMode.Collapsed) { - this.setExpanded(false); - } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { - // Start expanded if still in progress. - // streamingCompleted is true when look-ahead finds subsequent non-pinnable - // parts, meaning this thinking part won't receive more content. - this.setExpanded(!this.streamingCompleted && !this.element.isComplete); - } else { - this.setExpanded(false); - } - - const node = this.domNode; - if (this._hoverChevron) { - this._register(addDisposableListener(this._hoverChevron, EventType.CLICK, event => { - EventHelper.stop(event, true); - this.toggleExpanded(); - })); - } - - this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); - this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - node.appendChild(this._externalResourceWidget.domNode); - - if (!this.streamingCompleted && !this.element.isComplete) { - if (!this.fixedScrollingMode) { - node.classList.add('chat-thinking-active'); - } - } - - if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { - this.setShimmerTitle(extractedTitle); - } - - if (this.fixedScrollingMode) { - node.classList.add('chat-thinking-fixed-mode'); - this.currentTitle = this.defaultTitle; - } - - this._register(toDisposable(() => { - for (const d of this.ownedToolParts.values()) { - d.dispose(); - } - this.ownedToolParts.clear(); - })); - - this._register(toDisposable(() => { - for (const result of this.summaryRowResults) { - result?.dispose(); - } - for (const result of this.retiredSummaryRowResults) { - result.dispose(); - } - })); - - this._register(autorun(r => { - const isExpanded = this._isExpanded.read(r); - // Materialize lazy items when first expanded - if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { - this.hasExpandedOnce = true; - // Flush pending removals so that completed hidden tools are removed from lazyItems before materialization - this.processPendingRemovals(); - for (const item of this.lazyItems) { - this.materializeLazyItem(item); - } - } - - // If expanded but content matches title and there's nothing else to show, revert immediately. - // Skip this check while still streaming — more content will arrive. - if (isExpanded && !this.shouldAllowExpansion() && (this.streamingCompleted || this.element.isComplete)) { - this.setExpanded(false); - return; - } - - this._externalResourceWidget.setCollapsed(!isExpanded); - - // Fire when expanded/collapsed - this._onDidChangeHeight.fire(); - })); - - const label = this.lastExtractedTitle ?? ''; - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(label); - } - - if (this._collapseButton) { - this._register(this._collapseButton.onDidClick(() => { - if (this.fixedScrollingMode) { - if (this.streamingCompleted) { - this.domNode.classList.add('chat-thinking-fixed-mode-animated'); - } - return; - } - - if (this.streamingCompleted) { - return; - } - - const expanded = this.isExpanded(); - if (expanded) { - // Just expanded: show plain 'Working' with no detail - this.collapsedTitleBeforeExpansion = this.lastRenderedTitle ?? this.lastExtractedTitle; - this.setTitle(this.defaultTitle, true); - this.currentTitle = this.defaultTitle; - } else { - // Restore the title that was visible before expansion. Tool state - // updates can become less descriptive while the section is open. - const collapsedTitle = this.collapsedTitleBeforeExpansion ?? this.lastRenderedTitle ?? this.lastExtractedTitle; - this.collapsedTitleBeforeExpansion = undefined; - if (collapsedTitle) { - this.setTitle(collapsedTitle); - } else { - this.setTitle(this.defaultTitle, true); - this.currentTitle = this.defaultTitle; - } - } - })); - } - } - - protected override shouldInitEarly(): boolean { - return this.fixedScrollingMode && !this.streamingCompleted; - } - - protected override shouldAnimateContent(): boolean { - return !this.fixedScrollingMode; - } - - protected override shouldPrepareContentAnimation(): boolean { - return !this.fixedScrollingMode; - } - - protected override contentDidInitialize(): void { - if (this.fixedScrollingMode && this.streamingCompleted && this.scrollableElement) { - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = '0px'; - scrollableDomNode.getBoundingClientRect(); - } - } - - protected override get collapsibleKind(): string { - return 'thinking'; - } - - protected override expansionDidChange(expanded: boolean): void { - if (this.fixedScrollingMode && this.streamingCompleted) { - if (expanded) { - this.syncDimensionsAndScheduleScroll(); - } else { - this.updateCompletedScrollAnimationState(false); - } - } - } - - // @TODO: @justschen Convert to template for each setting? - protected override getThinkingIcon(_active: boolean, expanded: boolean): ThemeIcon { - if (this.streamingCompleted || this.element.isComplete) { - return Codicon.checkCompact; - } - return !this.fixedScrollingMode && expanded ? Codicon.chevronDownCompact : Codicon.circleFilledCompact; - } - - protected override initContent(): HTMLElement { - this.wrapper = this.createThinkingBody(); - if (!this.streamingCompleted) { - this.wrapper.classList.add('chat-thinking-streaming'); - } - - // Only create textContainer here if there's no pending lazy thinking item. - // If there's a lazy thinking item, it will be rendered via materializeLazyItem - // with the latest streaming content. - const hasLazyThinkingItems = this.lazyItems.some(item => item.kind === 'thinking'); - if (this.currentThinkingValue && !hasLazyThinkingItems) { - this.textContainer = $('.chat-thinking-item.markdown-content'); - this.wrapper.appendChild(this.textContainer); - this.renderMarkdown(this.currentThinkingValue); - } - - if (!this.streamingCompleted && !this.element.isComplete) { - const spinner = this.createThinkingSpinnerRow(this.getRandomWorkingMessage(WorkingMessageCategory.Thinking)); - this.workingSpinnerElement = spinner.row; - this.workingSpinnerLabel = spinner.label; - this.wrapper.appendChild(spinner.row); - this.updateWorkingSpinnerVisibility(); - } - - // wrap content in scrollable element for fixed scrolling mode - if (this.fixedScrollingMode) { - this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { - vertical: ScrollbarVisibility.Auto, - horizontal: ScrollbarVisibility.Hidden, - handleMouseWheel: true, - alwaysConsumeMouseWheel: false - })); - this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop))); - - let pendingMutationRefresh: IDisposable | undefined; - const mutationObserver = new MutationObserver(() => { - if (pendingMutationRefresh) { - return; - } - pendingMutationRefresh = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { - pendingMutationRefresh = undefined; - if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - }); - mutationObserver.observe(this.wrapper, { childList: true, subtree: true }); - this._register({ - dispose: () => { - mutationObserver.disconnect(); - pendingMutationRefresh?.dispose(); - } - }); - - // Observe child elements for resizes (e.g. terminal output growing) - // so we can update scroll dimensions when the wrapper box is pinned at max-height. - this.childResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.child', () => { - if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { - return; - } - - this.syncDimensionsAndScheduleScroll(); - })); - if (this.textContainer) { - this._register(this.childResizeObserver.observe(this.textContainer)); - } - if (this.workingSpinnerElement) { - this._register(this.childResizeObserver.observe(this.workingSpinnerElement)); - } - - // Cache wrapper scrollHeight post-layout via ResizeObserver to avoid forced reflows. - const wrapperResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.wrapper', (entries) => { - if (entries[0]) { - this.lastKnownContentHeight = this.wrapper.scrollHeight; - if (this.streamingCompleted && this.isExpanded()) { - this.updateScrollDimensionsForCompletion(); - } else if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { - this.updateScrollDimensionsFromCache(); - } - } - })); - this.wrapperResizeObserverDisposable = this._register(wrapperResizeObserver.observe(this.wrapper)); - - // Once content exceeds max-height, the wrapper box size stops changing - // so ResizeObserver won't fire. Fall back to scrollHeight reads here. - this._register(this._onDidChangeHeight.event(() => { - if (!this.streamingCompleted && this.wrapperResizeObserverDisposable) { - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - return; - } - this.syncDimensionsAndScheduleScroll(); - })); - - this.syncDimensionsAndScheduleScroll(); - - this.updateDropdownClickability(); - return this.scrollableElement.getDomNode(); - } - - this.updateDropdownClickability(); - return this.wrapper; - } - - private handleScroll(scrollTop: number): void { - if (!this.scrollableElement || this.isUpdatingDimensions) { - return; - } - - this.lastKnownScrollTop = scrollTop; - const contentHeight = this.lastKnownContentHeight; - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - const maxScrollTop = contentHeight - viewportHeight; - this.autoScrollEnabled = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; - - this.updateFadeClasses(scrollTop, contentHeight, viewportHeight); - } - - private updateFadeClasses(scrollTop?: number, contentHeight?: number, viewportHeight?: number): void { - if (!this.fixedScrollingMode || this.streamingCompleted) { - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - return; - } - - const currentScrollTop = scrollTop ?? this.lastKnownScrollTop; - const currentContentHeight = contentHeight ?? this.lastKnownContentHeight; - const currentViewportHeight = viewportHeight ?? Math.min(currentContentHeight, THINKING_SCROLL_MAX_HEIGHT); - const maxScrollTop = currentContentHeight - currentViewportHeight; - - this.domNode.classList.toggle('chat-thinking-fade-top', currentScrollTop > 5); - this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5); - } - - // Fallback for non-ResizeObserver updates (onDidChangeHeight, initial setup). - private syncDimensionsAndScheduleScroll(): void { - if (this.pendingScrollDisposable) { - return; - } - this.pendingScrollDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { - this.pendingScrollDisposable = undefined; - if (this._store.isDisposed) { - return; - } - if (this.streamingCompleted) { - this.updateScrollDimensionsForCompletion(); - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - } - - /** - * Re-read scrollHeight from the DOM and update cached height if changed. - */ - private refreshContentHeight(): void { - if (!this.wrapper || !this.scrollableElement) { - return; - } - const newHeight = this.wrapper.scrollHeight; - if (newHeight && newHeight !== this.lastKnownContentHeight) { - this.lastKnownContentHeight = newHeight; - } - } - - private updateScrollDimensionsFromCache(): void { - if (!this.scrollableElement || this._store.isDisposed) { - return; - } - - const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed'); - if (!isCollapsed) { - return; - } - - const contentHeight = this.lastKnownContentHeight; - if (!contentHeight) { - return; - } - - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - - this.isUpdatingDimensions = true; - try { - const viewportWidth = this.scrollableElement.getDomNode().clientWidth; - this.scrollableElement.setScrollDimensions({ - width: viewportWidth, - scrollWidth: viewportWidth, - height: viewportHeight, - scrollHeight: contentHeight - }); - - if (this.autoScrollEnabled) { - this.scrollToBottom(contentHeight); - } - } finally { - this.isUpdatingDimensions = false; - } - - this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight); - this.updateDropdownClickability(contentHeight); - } - - private scrollToBottom(contentHeight: number): void { - if (!this.scrollableElement) { - return; - } - - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - - if (contentHeight > viewportHeight) { - const newScrollTop = contentHeight - viewportHeight; - this.lastKnownScrollTop = newScrollTop; - // Prevent reveal-on-scroll behavior from interfering with explicit bottom pinning. - this.scrollableElement.setRevealOnScroll(false); - this.scrollableElement.setScrollPosition({ scrollTop: newScrollTop }); - this.scrollableElement.setRevealOnScroll(true); - } - } - - /** - * updates scroll dimensions when streaming is complete. - */ - private updateScrollDimensionsForCompletion(): void { - if (!this.scrollableElement || !this.fixedScrollingMode) { - return; - } - - const contentHeight = this.wrapper.scrollHeight; - this.lastKnownContentHeight = contentHeight; - - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = `${contentHeight}px`; - const viewportWidth = scrollableDomNode.clientWidth; - this.scrollableElement.setScrollDimensions({ - width: viewportWidth, - scrollWidth: viewportWidth, - height: contentHeight, - scrollHeight: contentHeight - }); - this.lastKnownScrollTop = 0; - this.scrollableElement.setRevealOnScroll(false); - this.scrollableElement.setScrollPosition({ scrollTop: 0 }); - this.scrollableElement.setRevealOnScroll(true); - this.updateCompletedScrollAnimationState(this.isExpanded()); - } - - private updateCompletedScrollAnimationState(expanded: boolean): void { - if (!this.scrollableElement) { - return; - } - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = expanded ? `${this.lastKnownContentHeight}px` : '0px'; - scrollableDomNode.inert = !expanded; - } - - private renderMarkdown(content: string, reuseExisting?: boolean): void { - // Guard against rendering after disposal to avoid leaking disposables - if (this._store.isDisposed) { - return; - } - - // A later thinking part reassigns textContainer; retire stale row tracking - // so the predecessor's rendered rows stay frozen while this part renders. - if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { - this.retireSummaryRows(); - } - - const cleanedContent = content.trim(); - if (!cleanedContent) { - this._markdownResult.clear(); - this.clearSummaryRows(); - if (this.textContainer) { - clearNode(this.textContainer); - } - return; - } - - // Multi-header reasoning summaries render each header section as its own - // row so the dropdown reads as a list. Sibling rows need an attached container so their - // insertion isn't a no-op, so a detached (lazy) container falls through to - // single-block rendering until it is materialized. A block drops its leading - // header only when that header is the tracked title owner, so a grouped block - // never drops a header that isn't surfaced as the title. - const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; - const summaryRows = splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); - if (summaryRows && this.textContainer?.parentNode) { - this.renderSummaryRows(summaryRows); - return; - } - this.clearSummaryRows(); - - // If the entire content is bolded, strip the bold markers for rendering - const contentToRender = stripStandaloneBold(cleanedContent); - - const target = reuseExisting ? this._markdownResult.value?.element : undefined; - - const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(contentToRender), { - fillInIncompleteTokens: true, - asyncRenderCallback: this._asyncRenderCallback, - codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, - }, target); - this._markdownResult.value = rendered; - if (!target) { - if (this.textContainer) { - clearNode(this.textContainer); - this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); - this.textContainer.appendChild(rendered.element); - } - } - } - - /** Renders one summary row, reusing the row's element while its text only grows. */ - private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { - const previous = this.summaryRowResults[index]; - const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); - // A standalone header renders as plain text, not bold. - const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { - fillInIncompleteTokens: true, - asyncRenderCallback: this._asyncRenderCallback, - codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, - }, reuse ? previous?.element : undefined); - if (!reuse) { - clearNode(container); - container.appendChild(createThinkingIcon(Codicon.circleFilled)); - container.appendChild(rendered.element); - } - previous?.dispose(); - this.summaryRowResults[index] = rendered; - this.summaryRowTexts[index] = markdown; - } - - private renderSummaryRows(rows: string[]): void { - // Rows own the DOM in this mode; release the single-block renderer. - this._markdownResult.clear(); - - for (let i = 0; i < rows.length; i++) { - let container = this.summaryRowItems[i]; - if (!container) { - container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); - this.summaryRowItems[i] = container; - this.summaryRowTexts[i] = ''; - if (i === 0) { - clearNode(container); - } else { - this.summaryRowItems[i - 1].after(container); - } - } - if (this.summaryRowTexts[i] !== rows[i]) { - this.renderSummaryRow(container, i, rows[i]); - } - } - - // Streaming only appends, but guard against a shrinking row set on re-render. - for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { - this.summaryRowResults[i]?.dispose(); - if (this.summaryRowItems[i] !== this.textContainer) { - this.summaryRowItems[i].remove(); - } - } - this.summaryRowItems.length = rows.length; - this.summaryRowResults.length = rows.length; - this.summaryRowTexts.length = rows.length; - } - - /** Removes the extra summary rows and resets tracking, keeping the text container. */ - private clearSummaryRows(): void { - if (!this.summaryRowItems.length) { - return; - } - for (let i = 0; i < this.summaryRowItems.length; i++) { - this.summaryRowResults[i]?.dispose(); - if (i !== 0) { - this.summaryRowItems[i].remove(); - } - } - this.summaryRowItems = []; - this.summaryRowResults = []; - this.summaryRowTexts = []; - } - - /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ - private retireSummaryRows(): void { - for (const result of this.summaryRowResults) { - if (result) { - this.retiredSummaryRowResults.push(result); - } - } - this.summaryRowItems = []; - this.summaryRowResults = []; - this.summaryRowTexts = []; - } - - /** - * Records the leading header the primary summary block drops, derived from content - * so it is available at finalize even when the rows never lazily rendered (the - * collapsed-through-completion flow). First-writer wins: the first grouped block - * that is a multi-header summary owns the title, and only that header is dropped. - */ - private trackDroppedSummaryHeader(value: string): void { - if (this.droppedSummaryHeader) { - return; - } - const trimmed = value.trim(); - if (splitReasoningSummaryRows(trimmed, true)) { - this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); - if (this.fixedScrollingMode && this.droppedSummaryHeader && this.currentTitle !== this.droppedSummaryHeader) { - this.setTitle(this.droppedSummaryHeader); - } - } - } - - private setFinalizedTitle(title: string): void { - if (!this._collapseButton) { - return; - } - - const displayTitle = this.getFinalizedDisplayTitle(title); - this.clearTitleDetail(); - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; - this.forgetShimmerTitle(); - - const firstSpaceIndex = displayTitle.indexOf(' '); - if (firstSpaceIndex === -1) { - // Single word title, no need to split - labelElement.textContent = displayTitle; - } else { - const verb = displayTitle.substring(0, firstSpaceIndex); - const rest = displayTitle.substring(firstSpaceIndex); - - const verbSpan = $('span'); - verbSpan.textContent = verb; - labelElement.appendChild(verbSpan); - - const restSpan = $('span.chat-thinking-title-detail-text'); - restSpan.textContent = rest; - labelElement.appendChild(restSpan); - } - - // Show aggregated diff stats from edit pills (only when there are actual changes) - if (this.diffDataByPartId.size > 0) { - const { added, removed } = this._aggregatedDiff; - if (added > 0 || removed > 0) { - this.renderDiffButton(added, removed); - - const insertionsFragment = added === 1 ? localize('chat.thinking.insertions.one', "1 insertion") : localize('chat.thinking.insertions', "{0} insertions", added); - const deletionsFragment = removed === 1 ? localize('chat.thinking.deletions.one', "1 deletion") : localize('chat.thinking.deletions', "{0} deletions", removed); - this.setAriaLabel(localize('chat.thinking.titleWithDiff', "{0}, {1}, {2}", displayTitle, insertionsFragment, deletionsFragment)); - } else { - this.clearDiffButton(); - this.setAriaLabel(displayTitle); - } - } else { - this.clearDiffButton(); - this.setAriaLabel(displayTitle); - } - } - - private renderDiffButton(added: number, removed: number): void { - const resources = this.getAggregatedDiffResources(); - if (resources.length === 0) { - this.clearDiffButton(); - return; - } - - if (!this.diffButton) { - const collapseButton = this._collapseButton; - const container = collapseButton?.element.parentElement; - if (!container) { - return; - } - - collapseButton.element.classList.add('chat-thinking-title-with-diff'); - const button = this.diffButtonStore.add(new Button(container, {})); - button.element.classList.add('chat-thinking-title-diff'); - this.diffButtonStore.add(button.onDidClick(event => { - EventHelper.stop(event, true); - this.openDiffs(); - })); - this.diffButtonStore.add(this.hoverService.setupDelayedHover(button.element, { - content: localize('chat.thinking.viewChanges', "View File Changes"), - style: HoverStyle.Pointer, - })); - this.diffButton = button; - - if (this._hoverChevron) { - container.appendChild(this._hoverChevron); - } - } - - this.diffButton.element.replaceChildren( - $('span.label-added', {}, `+${added}`), - $('span.label-removed', {}, `-${removed}`), - ); - this.diffButton.setAriaLabel(localize( - 'chat.thinking.viewChangesAccessible', - 'View file changes, {0} lines added, {1} lines deleted', - added, - removed, - )); - } - - private clearDiffButton(): void { - this.diffButtonStore.clear(); - this.diffButton = undefined; - const collapseButton = this._collapseButton; - collapseButton?.element.classList.remove('chat-thinking-title-with-diff'); - const container = collapseButton?.element.parentElement; - if (collapseButton && container && this._hoverChevron) { - if (this.titleDetailContainer?.parentElement === container) { - container.appendChild(this._hoverChevron); - } else { - collapseButton.element.appendChild(this._hoverChevron); - } - } - } - - private getAggregatedDiffResources(): IChatContentPartDiffResource[] { - const result = new Map(); - - for (const data of this.diffDataByPartId.values()) { - for (const resource of data.resources) { - const key = getComparisonKey(resource.resource); - const existing = result.get(key); - if (existing) { - existing.resource = resource.resource; - existing.modifiedURI = resource.modifiedURI; - } else { - result.set(key, { ...resource }); - } - } - } - - return [...result.values()].filter(resource => resource.originalURI !== undefined || resource.modifiedURI !== undefined); - } - - private openDiffs(): void { - const resources = this.getAggregatedDiffResources(); - if (resources.length === 0) { - return; - } - - const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); - this.editorService.openEditor({ - multiDiffSource: source, - label: localize('chat.thinking.changes.title', "Section File Changes"), - resources: resources.map(resource => ({ - original: { resource: resource.originalURI }, - modified: { resource: resource.modifiedURI }, - goToFileResource: resource.resource, - })), - }); - } - - private getFinalizedDisplayTitle(title: string): string { - if (this.thinkingDisplayMode !== ThinkingDisplayMode.Collapsed || !this.containsReasoning || this.containsGroupedItems || !this.reasoningDurationMs) { - return title; - } - - const seconds = Math.ceil(this.reasoningDurationMs / 1000); - const duration = localize('chat.thinking.duration.seconds', "{0}s", seconds); - return localize('chat.thinking.titleWithDuration', "{0} - {1}", title, duration); - } - - public hasReasoningContent(): boolean { - return this.containsReasoning; - } - - public hasGroupedItems(): boolean { - return this.containsGroupedItems; - } - - private recordReasoningContent(content: string): void { - if (!content.trim()) { - return; - } - this.containsReasoning = true; - } - - private setDropdownClickable(clickable: boolean): void { - if (this._collapseButton) { - this._collapseButton.element.style.pointerEvents = clickable ? 'auto' : 'none'; - } - - if (!clickable && this.streamingCompleted) { - this.setFinalizedTitle(this.lastExtractedTitle ?? this.currentTitle); - } - } - - private shouldAllowExpansion(): boolean { - // Multiple tool invocations or lazy items mean there's content to show - if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { - return true; - } - - // Count meaningful children in the wrapper (exclude the working spinner) - if (this.wrapper) { - const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; - if (meaningfulChildren > 1) { - return true; - } - } - - const contentWithoutTitle = this.currentThinkingValue.trim(); - const titleToCompare = this.lastExtractedTitle ?? this.currentTitle; - - const stripMarkdown = (text: string) => { - return text - .replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1').trim(); - }; - - const strippedContent = stripMarkdown(contentWithoutTitle); - // If content is empty or matches the title exactly, nothing to expand - return !(!strippedContent || strippedContent === titleToCompare); - } - - private updateDropdownClickability(knownContentHeight?: number): void { - let allowExpansion = this.shouldAllowExpansion(); - - // don't allow feedback on fixed scrolling before reaching max height. - if (allowExpansion && this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this.wrapper) { - // Use only the cached height — never read scrollHeight here to avoid forced reflows. - // If the cache is empty, conservatively disallow expansion; the ResizeObserver - // will populate lastKnownContentHeight and trigger another call once layout settles. - const contentHeight = knownContentHeight ?? this.lastKnownContentHeight; - if (!contentHeight || contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { - allowExpansion = false; - } - } - - if (!allowExpansion && this.isExpanded() && (this.streamingCompleted || this.element.isComplete)) { - this.setExpanded(false); - } - this.setDropdownClickable(allowExpansion); - } - - private appendToWrapper(element: HTMLElement): void { - if (!this.wrapper) { - return; - } - if (this.workingSpinnerElement && this.workingSpinnerElement.parentNode === this.wrapper) { - this.wrapper.insertBefore(element, this.workingSpinnerElement); - } else { - this.wrapper.appendChild(element); - } - } - - private updateWorkingSpinnerVisibility(reader?: IReader): void { - if (!this.wrapper || !this.workingSpinnerElement) { - return; - } - - const hasRunningTerminalTool = this.toolInvocations.some(toolInvocation => { - const terminalData = toolInvocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; - if (terminalData?.kind !== 'terminal' || terminalData.terminalCommandState?.exitCode !== undefined) { - return false; - } - - return !IChatToolInvocation.isComplete(toolInvocation, reader); - }); - - const isAttached = this.workingSpinnerElement.parentNode === this.wrapper; - if (hasRunningTerminalTool && isAttached) { - this.workingSpinnerElement.remove(); - this._onDidChangeHeight.fire(); - } else if (!hasRunningTerminalTool && !isAttached && !this.streamingCompleted && !this.element.isComplete) { - this.wrapper.appendChild(this.workingSpinnerElement); - this._onDidChangeHeight.fire(); - } - } - - public resetId(): void { - this.id = undefined; - } - - public collapseContent(): void { - this.setExpanded(false); - } - - public updateThinking(content: IChatThinkingPart): void { - // If disposed, ignore late updates coming from renderer diffing - if (this._store.isDisposed) { - return; - } - this.content = content; - this.reasoningDurationMs = content.reasoningDurationMs; - - // Update any pending lazy thinking item with matching ID so that - // when materialized, it will have the latest streaming content - for (const lazyItem of this.lazyItems) { - if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) { - lazyItem.content = content; - break; - } - } - - const raw = extractTextFromPart(content); - this.recordReasoningContent(raw); - const next = raw; - if (next === this.currentThinkingValue) { - return; - } - const previousValue = this.currentThinkingValue; - const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); - this.currentThinkingValue = next; - this.trackDroppedSummaryHeader(next); - this.renderMarkdown(next, reuseExisting); - - if (this.fixedScrollingMode && this.scrollableElement) { - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - } - - const extractedTitle = extractTitleFromThinkingContent(raw); - if (extractedTitle && extractedTitle !== this.currentTitle) { - if (!this.extractedTitles.includes(extractedTitle)) { - this.extractedTitles.push(extractedTitle); - } - this.lastExtractedTitle = extractedTitle; - } - - if (!extractedTitle || extractedTitle === this.currentTitle) { - return; - } - - const label = this.lastExtractedTitle ?? ''; - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(label); - } - - this.updateDropdownClickability(); - } - - public getIsActive(): boolean { - return this.isActive; - } - - /** - * Returns true when this thinking part has no meaningful content to display: - * no tool invocations, no lazy items, no hooks, and no thinking text. - * This happens when a tool is removed from thinking (e.g. due to confirmation) - * and the thinking part was only created to hold that tool. - */ - public isEffectivelyEmpty(): boolean { - this.processPendingRemovals(); - if (this.toolInvocationCount > 0 || this.lazyItems.length > 0 || this.hookCount > 0) { - return false; - } - if (this.currentThinkingValue.trim().length > 0) { - return false; - } - return true; - } - - public markAsInactive(): void { - this.isActive = false; - this.domNode.classList.remove('chat-thinking-active'); - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - this.processPendingRemovals(); - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - - // Clear the attached-to-thinking flag on all tool invocations - for (const toolInvocation of this.toolInvocations) { - toolInvocation.isAttachedToThinking = false; - } - } - - public finalizeTitleIfDefault(): void { - this.processPendingRemovals(); - - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded - if (this.wrapper) { - this.wrapper.classList.remove('chat-thinking-streaming'); - } - this.domNode.classList.remove('chat-thinking-active'); - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - this.streamingCompleted = true; - this.setContentAnimationEnabled(!this.fixedScrollingMode); - - // Now that streaming is complete, render any aggregated images that were - // deferred while scrolling was pinned in fixed scrolling mode. - this.flushPendingExternalResources(); - - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - - if (this._collapseButton) { - this._collapseButton.icon = Codicon.checkCompact; - } - - // Update scroll dimensions now that streaming is complete - // This removes unnecessary scrollbar when content fits - this.updateScrollDimensionsForCompletion(); - - this.updateDropdownClickability(); - - // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. - if (this.droppedSummaryHeader) { - this.currentTitle = this.droppedSummaryHeader; - this.content.generatedTitle = this.droppedSummaryHeader; - this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); - this.setFinalizedTitle(this.droppedSummaryHeader); - return; - } - - if (this.content.generatedTitle) { - this.currentTitle = this.content.generatedTitle; - this.setGeneratedTitleOnAllParts(this.content.generatedTitle); - this.setFinalizedTitle(this.content.generatedTitle); - return; - } - - // Reuse any existing generated title from tool invocations or thinking parts. - const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle - ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; - if (existingTitle) { - this.currentTitle = existingTitle; - this.content.generatedTitle = existingTitle; - this.setGeneratedTitleOnAllParts(existingTitle); - this.setFinalizedTitle(existingTitle); - return; - } - - // Only check the persisted cache when re-rendering (tool invocations are - // serialized), not during live streaming. Reasoning-only blocks (no tools) - // are keyed off the stable thinking part id so their generated headers are - // also restored on reload (non-local sessions only). - const allToolsSerialized = this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized'); - if (allToolsSerialized && !LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { - const cacheId = this.getTitleCacheId(); - if (cacheId) { - const cachedTitle = this.getCachedTitle(cacheId); - if (cachedTitle) { - this.currentTitle = cachedTitle; - this.content.generatedTitle = cachedTitle; - this.setGeneratedTitleOnAllParts(cachedTitle); - this.setFinalizedTitle(cachedTitle); - return; - } - } - } - - // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position - if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') { - // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now - if (!this.singleItemInfo) { - const lazyItem = this.lazyItems.find(item => item.kind === 'tool' && item.originalParent); - if (lazyItem && lazyItem.kind === 'tool') { - const toolInvocation = lazyItem.toolInvocationOrMarkdown && (lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? lazyItem.toolInvocationOrMarkdown : undefined; - const result = lazyItem.lazy.value; - this.appendItemToDOM(result.domNode, lazyItem.toolInvocationId, lazyItem.toolInvocationOrMarkdown, lazyItem.originalParent); - if (result.disposable) { - const toolCallId = toolInvocation?.toolCallId; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } - } - if (this.singleItemInfo && this.restoreSingleItemToOriginalPosition()) { - return; - } - } - - // if exactly one actual extracted title and no tool invocations, use that as the final title. - if (this.extractedTitles.length === 1 && this.toolInvocationCount === 0) { - const title = this.extractedTitles[0]; - this.currentTitle = title; - this.content.generatedTitle = title; - this.setGeneratedTitleOnAllParts(title); - this.setFinalizedTitle(title); - return; - } - - const generateTitles = this.configurationService.getValue(ChatConfiguration.ThinkingGenerateTitles) ?? true; - if (!generateTitles) { - this.setFallbackTitle(); - return; - } - - this.generateTitleViaLLM(); - } - - private setGeneratedTitleOnAllParts(title: string): void { - for (const toolInvocation of this.toolInvocations) { - toolInvocation.generatedTitle = title; - } - for (const thinkingPart of this.allThinkingParts) { - thinkingPart.generatedTitle = title; - } - } - - private loadTitleCache(): Record { - return this.storageService.getObject>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {}; - } - - private saveTitleCache(cache: Record): void { - if (Object.keys(cache).length === 0) { - this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE); - } else { - this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE); - } - } - - private getTitleCacheKey(id: string): string { - return `${chatSessionResourceToId(this.element.sessionResource)}:${id}`; - } - - /** - * Stable id used to persist/restore the generated title. Tool-based blocks - * key off the last tool call id; reasoning-only blocks fall back to the - * thinking part id so their headers also survive a session reload. - */ - private getTitleCacheId(): string | undefined { - const lastTool = this.toolInvocations[this.toolInvocations.length - 1]; - if (lastTool) { - return lastTool.toolCallId; - } - return this.allThinkingParts.find(t => t.id)?.id ?? this.content.id; - } - - private getCachedTitle(id: string): string | undefined { - const entry = this.loadTitleCache()[this.getTitleCacheKey(id)]; - if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) { - return undefined; - } - return entry.title; - } - - private setCachedTitle(id: string, title: string): void { - const cache = this.loadTitleCache(); - const now = Date.now(); - - // Evict expired entries on write - for (const key of Object.keys(cache)) { - if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) { - delete cache[key]; - } - } - - cache[this.getTitleCacheKey(id)] = { title, storedAt: now }; - - // Cap size by dropping oldest entries - const keys = Object.keys(cache); - if (keys.length > TITLE_CACHE_MAX_ENTRIES) { - const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt); - for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) { - delete cache[sorted[i]]; - } - } - - this.saveTitleCache(cache); - } - - private async generateTitleViaLLM(): Promise { - const cts = new CancellationTokenSource(); - const timeout = setTimeout(() => cts.cancel(), 5000); - - try { - const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); - if (!models.length) { - this.setFallbackTitle(); - return; - } - - if (cts.token.isCancellationRequested) { - this.setFallbackTitle(); - return; - } - - let context: string; - if (this.extractedTitles.length > 0) { - context = this.extractedTitles.join(', '); - } else { - context = this.currentThinkingValue.substring(0, 1000); - } - - const prompt = `Summarize the following content in a SINGLE sentence (under 10 words) using past tense. Follow these rules strictly: - - OUTPUT FORMAT: - - MUST be a single sentence - - MUST be under 10 words - - The FIRST word MUST be a past tense verb (e.g. "Updated", "Reviewed", "Created", "Searched", "Analyzed") - - No quotes, no trailing punctuation - - GENERAL: - - The content may include tool invocations (file edits, reads, searches, terminal commands), reasoning headers, or raw thinking text - - For reasoning headers or thinking text (no tool calls), summarize WHAT was considered/analyzed, NOT that thinking occurred - - For thinking-only summaries, use phrases like: "Considered...", "Planned...", "Analyzed...", "Reviewed..." - - TOOL NAME FILTERING: - - NEVER include tool names like "Replace String in File", "Multi Replace String in File", "Create File", "Read File", etc. in the output - - If an action says "Edited X and used Replace String in File", output ONLY the action on X - - Tool names describe HOW something was done, not WHAT was done - always omit them - - VOCABULARY - Use varied synonyms for natural-sounding summaries: - - For edits: "Updated", "Modified", "Changed", "Refactored", "Fixed", "Adjusted" - - For reads: "Reviewed", "Examined", "Checked", "Inspected", "Analyzed", "Explored" - - For creates: "Created", "Added", "Generated" - - For searches: "Searched for", "Looked up", "Investigated" - - For terminal: "Ran command", "Executed" - - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" - - Choose the synonym that best fits the context - -${this.hookCount > 0 ? `BLOCKED/DENIED CONTENT (hooks detected): - - Only mention "blocked" if the content explicitly includes hook results that blocked or warned about a tool (e.g. "Blocked terminal" or "Warning for read_file") - - If blocked items are present alongside normal tool calls, briefly note the block but do NOT let it dominate the summary: e.g. "Updated file.ts, blocked terminal" - - ` : `IMPORTANT: Do NOT use words like "blocked", "denied", or "tried" in the summary - there are no hooks or blocked items in this content. Just summarize normally. - - `}RULES FOR TOOL CALLS: - 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " - 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) - 3. If exactly ONE file was read: Start with a read synonym + "" (include actual filename) - 4. If MULTIPLE files were edited: Start with an edit synonym + "X files" - 5. If MULTIPLE files were read: Start with a read synonym + "X files" - 6. If BOTH edits AND reads occurred on DIFFERENT files: Combine them naturally - 7. For searches: Say "searched for " or "looked up " with the actual search term, NOT "searched for files" - 8. After the file info, you may add a brief summary of other actions if space permits - 9. NEVER say "1 file" - always use the actual filename when there's only one file - - RULES FOR REASONING HEADERS (no tool calls): - 1. If the input contains reasoning/analysis headers without actual tool invocations, summarize the main topic and what was considered - 2. Use past tense verbs that indicate thinking, not doing: "Considered", "Planned", "Analyzed", "Evaluated" - 3. Focus on WHAT was being thought about, not that thinking occurred - - RULES FOR RAW THINKING TEXT: - 1. Extract the main topic or question being considered from the text - 2. Identify any specific files, functions, or concepts mentioned - 3. Summarize as "Analyzed " or "Considered " - 4. If discussing code structure: "Reviewed " - 5. If discussing a problem: "Analyzed " - 6. If discussing implementation: "Planned " - - EXAMPLES WITH TOOLS: - - "Read HomePage.tsx, Edited HomePage.tsx" → "Reviewed and updated HomePage.tsx" - - "Edited HomePage.tsx" → "Updated HomePage.tsx" - - "Edited config.css and used Replace String in File" → "Modified config.css" - - "Edited App.tsx, used Multi Replace String in File" → "Refactored App.tsx" - - "Read config.json, Read package.json" → "Reviewed 2 files" - - "Edited App.tsx, Read utils.ts" → "Updated App.tsx and checked utils.ts" - - "Edited App.tsx, Read utils.ts, Read types.ts" → "Updated App.tsx and reviewed 2 files" - - "Edited index.ts, Edited styles.css, Ran terminal command" → "Modified 2 files and ran command" - - "Read README.md, Searched for AuthService" → "Checked README.md and searched for AuthService" - - "Searched for login, Searched for authentication" → "Searched for login and authentication" - - "Edited api.ts, Edited models.ts, Read schema.json" → "Updated 2 files and reviewed schema.json" - - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" - - "Searched codebase for error handling" → "Looked up error handling" - -${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): - - "Blocked terminal, Edited config.ts" → "Edited config.ts, terminal was blocked" - - "Blocked terminal, Blocked read_file" → "Two tools were blocked by hooks" - - "Warning for read_file, Edited utils.ts" → "Edited utils.ts with a hook warning" - - ` : ''}EXAMPLES WITH REASONING HEADERS (no tools): - - "Analyzing component architecture" → "Considered component architecture" - - "Planning refactor strategy" → "Planned refactor strategy" - - "Reviewing error handling approach, Considering edge cases" → "Analyzed error handling approach" - - "Understanding the codebase structure" → "Reviewed codebase structure" - - "Thinking about implementation options" → "Considered implementation options" - - EXAMPLES WITH RAW THINKING TEXT: - - "I need to understand how the authentication flow works in this app..." → "Analyzed authentication flow" - - "Let me think about how to refactor this component to be more maintainable..." → "Planned component refactoring" - - "The error seems to be coming from the database connection..." → "Investigated database connection issue" - - "Looking at the UserService class, I see it handles..." → "Reviewed UserService implementation" - - Content: ${context}`; - - const response = await this.languageModelsService.sendChatRequest( - models[0], - undefined, - [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], - {}, - cts.token - ); - - let generatedTitle = ''; - for await (const part of response.stream) { - if (cts.token.isCancellationRequested) { - break; - } - if (Array.isArray(part)) { - for (const p of part) { - if (p.type === 'text') { - generatedTitle += p.value; - } - } - } else if (part.type === 'text') { - generatedTitle += part.value; - } - } - - if (cts.token.isCancellationRequested) { - this.setFallbackTitle(); - return; - } - - await response.result; - generatedTitle = generatedTitle.trim(); - - if (generatedTitle.includes('can\'t assist with that')) { - this.setFallbackTitle(); - return; - } - - if (generatedTitle && !this._store.isDisposed) { - this.currentTitle = generatedTitle; - this.setFinalizedTitle(generatedTitle); - this.content.generatedTitle = generatedTitle; - this.setGeneratedTitleOnAllParts(generatedTitle); - - // Persist to storage for non-local sessions only - if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { - const cacheId = this.getTitleCacheId(); - if (cacheId) { - this.setCachedTitle(cacheId, generatedTitle); - } - } - - return; - } - } catch (error) { - // fall through to default title - } finally { - clearTimeout(timeout); - cts.dispose(); - } - - this.setFallbackTitle(); - } - - private restoreSingleItemToOriginalPosition(): boolean { - if (!this.singleItemInfo) { - return false; - } - - const { element, thinkingWrapper, originalParent, originalNextSibling, restoreToOriginalParent, toolInvocation } = this.singleItemInfo; - - const hasOtherThinkingItems = this.wrapper && Array.from(this.wrapper.children).some(child => - child !== thinkingWrapper && child !== this.workingSpinnerElement - ); - if (hasOtherThinkingItems) { - this.singleItemInfo = undefined; - return false; - } - - const precedingToolInvocationPart = isHTMLElement(originalNextSibling) && originalNextSibling.parentElement === originalParent - ? originalNextSibling.previousElementSibling - : originalParent.lastElementChild; - if (restoreToOriginalParent) { - if (originalNextSibling && originalNextSibling.parentNode === originalParent) { - originalParent.insertBefore(element, originalNextSibling); - } else { - originalParent.appendChild(element); - } - } else if (precedingToolInvocationPart?.classList.contains('chat-tool-invocation-part')) { - precedingToolInvocationPart.appendChild(element); - } else if (originalNextSibling && originalNextSibling.parentNode === originalParent) { - originalParent.insertBefore(element, originalNextSibling); - } else { - originalParent.appendChild(element); - } - thinkingWrapper.remove(); - - if (toolInvocation) { - this.toolWrappersByCallId.delete(toolInvocation.toolCallId); - this.toolIconsByCallId.delete(toolInvocation.toolCallId); - toolInvocation.isAttachedToThinking = false; - } - - hide(this.domNode); - this.singleItemInfo = undefined; - return true; - } - - private updateAggregatedDiff(): void { - let totalAdded = 0; - let totalRemoved = 0; - for (const data of this.diffDataByPartId.values()) { - totalAdded += data.added; - totalRemoved += data.removed; - } - this._aggregatedDiff = { added: totalAdded, removed: totalRemoved }; - - // Re-render the finalized title if streaming is already complete, - // since diff events from edit pills may arrive after the title was set. - if (this.streamingCompleted || this.element.isComplete) { - this.setFinalizedTitle(this.currentTitle); - } - } - - private setFallbackTitle(): void { - const finalLabel = this.appendedItemCount > 0 - ? this.appendedItemCount === 1 - ? localize('chat.thinking.finished.withStepsSingular', 'Finished with 1 step') - : localize('chat.thinking.finished.withStepsPlural', 'Finished with {0} steps', this.appendedItemCount) - : localize('chat.thinking.finished', 'Finished Working'); - - this.currentTitle = finalLabel; - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded - if (this.wrapper) { - this.wrapper.classList.remove('chat-thinking-streaming'); - } - this.domNode.classList.remove('chat-thinking-active'); - this.streamingCompleted = true; - - // Render any aggregated images that were deferred during fixed scrolling streaming. - this.flushPendingExternalResources(); - - if (this._collapseButton) { - this._collapseButton.icon = Codicon.checkCompact; - this.setFinalizedTitle(finalLabel); - } - - this.updateDropdownClickability(); - } - - /** - * Appends a tool invocation or content item to the thinking group. - * The factory is called lazily - only when the thinking section is expanded. - * If already expanded, the factory is called immediately. - * - * When the caller has already created the content part eagerly (for example, a - * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass - * that part as `eagerDisposable` so it is registered on this thinking part - * immediately. Otherwise, if the thinking section is collapsed and the lazy item - * is never materialized (because the user never expands it), the eagerly-created - * part would leak: its disposable is only referenced from inside the factory's - * closure, which nothing ever calls. - */ - public appendItem( - factory: () => { domNode: HTMLElement; disposable?: IDisposable }, - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata, - originalParent?: HTMLElement, - onDidChangeDiff?: Event, - eagerDisposable?: IDisposable, - ): void { - this.processPendingRemovals(); - this.containsGroupedItems = true; - - // Track tool invocation metadata immediately (for title generation) - this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); - this.updateWorkingSpinnerVisibility(); - this.appendedItemCount++; - - // Listen for diff changes from edit pills - if (onDidChangeDiff && toolInvocationId) { - this.diffDataByPartId.set(toolInvocationId, { added: 0, removed: 0, resources: [] }); - this._register(onDidChangeDiff(data => { - this.diffDataByPartId.set(toolInvocationId, data); - this.updateAggregatedDiff(); - })); - } - - // Register any caller-owned disposable up-front so it is always cleaned up - // with this thinking part, even if the lazy item is never materialized. - if (eagerDisposable) { - this._register(eagerDisposable); - } - - // get random message based on tool type - if (this.workingSpinnerLabel) { - const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); - } - - // If expanded or has been expanded once, render immediately - if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { - const result = factory(); - this.appendItemToDOM(result.domNode, toolInvocationId, toolInvocationOrMarkdown, originalParent); - if (result.disposable) { - const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } else { - // Defer rendering until expanded - const item: ILazyToolItem = { - kind: 'tool', - lazy: new Lazy(factory), - toolInvocationId, - toolInvocationOrMarkdown, - originalParent, - isHook: !toolInvocationOrMarkdown && !!toolInvocationId, - }; - this.lazyItems.push(item); - } - - this.updateDropdownClickability(); - } - - public removeMaterializedItem(toolCallId: string): void { - this.toolDisposables.deleteAndDispose(toolCallId); - this.ownedToolParts.delete(toolCallId); - - const wrapper = this.toolWrappersByCallId.get(toolCallId); - if (wrapper) { - this.toolWrappersByCallId.delete(toolCallId); - this.toolIconsByCallId.delete(toolCallId); - } - - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); - - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId - ); - if (toolInvocationsIndex !== -1) { - // Use the tracked displayed label (which may differ from invocationMessage - // for streaming edit tools that show "Editing files") - const label = this.toolLabelsByCallId.get(toolCallId); - if (label) { - const titleIndex = this.extractedTitles.indexOf(label); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - } - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - this.toolLabelsByCallId.delete(toolCallId); - - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - - this.updateWorkingSpinnerVisibility(); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - - /** - * Removes a markdown edit pill child by its part ID (codeblocksPartId). - */ - public removeEditPillByPartId(partId: string): void { - let removed = false; - - const lazyIndex = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === partId); - if (lazyIndex !== -1) { - this.lazyItems.splice(lazyIndex, 1); - removed = true; - } - - if (this.diffDataByPartId.delete(partId)) { - this.updateAggregatedDiff(); - removed = true; - } - - if (removed) { - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - } - - /** - * removes/re-establishes a lazy item from the thinking container - * this is needed so we can check if there are confirmations still needed - */ - public removeLazyItem(toolInvocationId: string): boolean { - const index = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === toolInvocationId); - if (index === -1) { - return false; - } - - const removedItem = this.lazyItems[index]; - this.lazyItems.splice(index, 1); - this.appendedItemCount--; - if (removedItem.kind === 'tool' && removedItem.isHook) { - this.hookCount = Math.max(0, this.hookCount - 1); - } else { - this.toolInvocationCount--; - } - - // Clear the attached-to-thinking flag on the removed tool invocation - if (removedItem.kind === 'tool' && removedItem.toolInvocationOrMarkdown && (removedItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { - removedItem.toolInvocationOrMarkdown.isAttachedToThinking = false; - - // Keep extractedTitles in sync when a lazy tool leaves the thinking container. - // Use the tracked displayed label (which may differ from invocationMessage - // for streaming edit tools that show "Editing files") - const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - const label = this.toolLabelsByCallId.get(toolCallId); - if (label) { - const titleIndex = this.extractedTitles.indexOf(label); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - } - this.toolLabelsByCallId.delete(toolCallId); - } - - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolId === toolInvocationId - ); - if (toolInvocationsIndex !== -1) { - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - - this.updateDropdownClickability(); - this.updateWorkingSpinnerVisibility(); - return true; - } - - private processPendingRemovals(): void { - this.pendingRemovalFlushDisposable?.dispose(); - this.pendingRemovalFlushDisposable = undefined; - - if (this.pendingRemovals.length === 0) { - return; - } - - const pendingRemovals = this.pendingRemovals; - this.pendingRemovals = []; - - for (const pending of pendingRemovals) { - this.removeStreamingToolEntry(pending.toolCallId, pending.toolLabel); - } - } - - private schedulePendingRemovalsFlush(): void { - if (this.pendingRemovalFlushDisposable) { - return; - } - - this.pendingRemovalFlushDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { - this.pendingRemovalFlushDisposable = undefined; - if (this._store.isDisposed) { - return; - } - - this.processPendingRemovals(); - }); - } - - // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. - private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { - this.toolDisposables.deleteAndDispose(toolCallId); - this.ownedToolParts.get(toolCallId)?.dispose(); - this.ownedToolParts.delete(toolCallId); - - const wrapper = this.toolWrappersByCallId.get(toolCallId); - if (wrapper) { - wrapper.remove(); - this.toolWrappersByCallId.delete(toolCallId); - this.toolIconsByCallId.delete(toolCallId); - } - - // make sure to remove any lazy item as well - const lazyIndex = this.lazyItems.findIndex(item => - item.kind === 'tool' && - item.toolInvocationOrMarkdown && - (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && - item.toolInvocationOrMarkdown.toolCallId === toolCallId - ); - if (lazyIndex !== -1) { - const removedLazyItem = this.lazyItems[lazyIndex]; - if (removedLazyItem.kind === 'tool' && removedLazyItem.toolInvocationOrMarkdown && (removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { - removedLazyItem.toolInvocationOrMarkdown.isAttachedToThinking = false; - } - this.lazyItems.splice(lazyIndex, 1); - } - - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId - ); - if (toolInvocationsIndex !== -1) { - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - - const titleIndex = this.extractedTitles.indexOf(toolLabel); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - this.toolLabelsByCallId.delete(toolCallId); - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - this.updateWorkingSpinnerVisibility(); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - - private trackToolMetadata( - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata - ): void { - if (!toolInvocationId) { - return; - } - - // Track hooks separately: if toolInvocationOrMarkdown is undefined, it's a hook item - const isHook = !toolInvocationOrMarkdown; - if (isHook) { - this.hookCount++; - } else { - this.toolInvocationCount++; - } - - // Shift default title from 'Thinking' to 'Working' once we have tool calls - if (this.toolInvocationCount === 1) { - this.defaultTitle = this.workingTitle; - } - - let toolCallLabel: string; - let toolCallTitle: ChatThinkingTitle; - - const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); - if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { - const invocationMessage = toolInvocationOrMarkdown.invocationMessage; - - // For edit-type tools that are still streaming, use a friendlier label - // instead of the generic tool display name (e.g. "Replace String in File") - const isStreamingEditTool = toolInvocationOrMarkdown.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocationOrMarkdown) && isGenericEditToolId(toolInvocationOrMarkdown.toolId); - if (isStreamingEditTool) { - toolCallTitle = localize('chat.thinking.editingFiles', 'Editing files'); - } else { - toolCallTitle = invocationMessage; - } - toolCallLabel = getThinkingTitleValue(toolCallTitle); - - this.toolInvocations.push(toolInvocationOrMarkdown); - - // Track the displayed label for consistent cleanup - const toolCallId = toolInvocationOrMarkdown.toolCallId; - this.toolLabelsByCallId.set(toolCallId, toolCallLabel); - - // Render external image pills for serialized (already-completed) tool invocations - if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { - this.updateExternalResourceParts(toolInvocationOrMarkdown); - - // Queue hidden serialized tools for removal immediately. - if (IChatToolInvocation.isEffectivelyHidden(toolInvocationOrMarkdown)) { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: toolCallLabel }); - this.schedulePendingRemovalsFlush(); - } - } - - // track state for live/still streaming tools, excluding serialized tools - if (toolInvocationOrMarkdown.kind === 'toolInvocation') { - let currentToolLabel = toolCallLabel; - let isComplete = false; - let isStreaming = IChatToolInvocation.isStreaming(toolInvocationOrMarkdown); - - const toolStore = new DisposableStore(); - this.toolDisposables.set(toolInvocationOrMarkdown.toolCallId, toolStore); - - const updateTitle = (updatedTitle: ChatThinkingTitle) => { - const updatedMessage = getThinkingTitleValue(updatedTitle); - if (updatedMessage && !thinkingTitleEqual(updatedTitle, toolCallTitle)) { - // replace old title if exists, otherwise add new - if (updatedMessage !== currentToolLabel) { - const oldIndex = this.extractedTitles.indexOf(currentToolLabel); - const updatedIndex = this.extractedTitles.indexOf(updatedMessage); - - if (oldIndex !== -1) { - if (updatedIndex !== -1 && updatedIndex !== oldIndex) { - this.extractedTitles.splice(oldIndex, 1); - } else { - this.extractedTitles[oldIndex] = updatedMessage; - } - } else if (updatedIndex === -1) { - this.extractedTitles.push(updatedMessage); - } - currentToolLabel = updatedMessage; - } - toolCallLabel = updatedMessage; - toolCallTitle = updatedTitle; - this.toolLabelsByCallId.set(toolCallId, updatedMessage); - this.lastExtractedTitle = updatedMessage; - - // make sure not to set title if expanded - if (!this.fixedScrollingMode && !this._isExpanded.read(undefined)) { - this.setTitle(updatedTitle); - } - } - }; - - const autorunDisposable = autorun(reader => { - if (isComplete) { - return; - } - - const currentState = toolInvocationOrMarkdown.state.read(reader); - this.updateWorkingSpinnerVisibility(reader); - - // queue item to be removed if it was streaming and presentation is hidden - if (isStreaming && currentState.type !== IChatToolInvocation.StateKind.Streaming) { - isStreaming = false; - - // Update terminal tool icon based on sandbox wrapping state - const termData = toolInvocationOrMarkdown.toolSpecificData as IChatTerminalToolInvocationData | undefined; - if (termData?.kind === 'terminal') { - const iconEl = this.toolIconsByCallId.get(toolCallId); - if (iconEl) { - const newIcon = termData.commandLine?.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal; - setThinkingIcon(iconEl, newIcon); - } - } - - if (toolInvocationOrMarkdown.presentation === 'hidden') { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); - this.schedulePendingRemovalsFlush(); - isComplete = true; - return; - } - } - - if (currentState.type === IChatToolInvocation.StateKind.Completed || - currentState.type === IChatToolInvocation.StateKind.Cancelled) { - // Remove tools that should be hidden now or after completion. - if (toolInvocationOrMarkdown.presentation === 'hidden' || toolInvocationOrMarkdown.presentation === 'hiddenAfterComplete') { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); - this.schedulePendingRemovalsFlush(); - } - - // Render image pills outside the collapsible area for completed tools - if (currentState.type === IChatToolInvocation.StateKind.Completed) { - this.updateExternalResourceParts(toolInvocationOrMarkdown); - const completedMessage = toolInvocationOrMarkdown.pastTenseMessage ?? toolInvocationOrMarkdown.invocationMessage; - const completedText = typeof completedMessage === 'string' ? completedMessage : completedMessage.value; - const iconElement = this.toolIconsByCallId.get(toolCallId); - if (iconElement && isNoProblemsFoundResult(toolInvocationOrMarkdown.toolId, completedText)) { - setThinkingIcon(iconElement, Codicon.search); - } - } - - isComplete = true; - return; - } - - // streaming - if (currentState.type === IChatToolInvocation.StateKind.Streaming) { - isStreaming = true; - const streamingMessage = currentState.streamingMessage.read(reader); - if (streamingMessage) { - updateTitle(streamingMessage); - } - return; - } - - // executing (something like `Replacing 67 lines.....`) - if (currentState.type === IChatToolInvocation.StateKind.Executing) { - const progressData = currentState.progress.read(reader); - if (progressData.message) { - updateTitle(progressData.message); - } else { - const invocationMsg = toolInvocationOrMarkdown.invocationMessage; - if (invocationMsg) { - updateTitle(invocationMsg); - } - } - return; - } - - // confirmations, failures, completed, other, etc - const invocationMsg = toolInvocationOrMarkdown.invocationMessage; - if (invocationMsg) { - updateTitle(invocationMsg); - } - }); - toolStore.add(autorunDisposable); - } - } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { - const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); - if (codeblockInfo?.uri) { - const filename = basename(codeblockInfo.uri); - toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); - } else { - toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); - } - toolCallTitle = toolCallLabel; - } else if (toolInvocationOrMarkdown?.kind === 'externalEdit') { - const filename = basename(toolInvocationOrMarkdown.uri); - switch (toolInvocationOrMarkdown.editKind) { - case 'create': - toolCallLabel = localize('chat.thinking.createdFile', 'Created {0}', filename); - break; - case 'delete': - toolCallLabel = localize('chat.thinking.deletedFile', 'Deleted {0}', filename); - break; - case 'rename': - toolCallLabel = localize('chat.thinking.renamedFile', 'Renamed {0}', filename); - break; - case 'edit': - toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); - break; - } - toolCallTitle = toolCallLabel; - } else { - toolCallLabel = toolInvocationId; - toolCallTitle = toolCallLabel; - } - - // Add tool call to extracted titles for LLM title generation - if (!this.extractedTitles.includes(toolCallLabel)) { - this.extractedTitles.push(toolCallLabel); - } - - this.lastExtractedTitle = toolCallLabel; - - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(toolCallTitle); - } - } - - private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { - if (toolInvocation.toolSpecificData?.kind === 'terminal') { - return; - } - - // In fixed scrolling mode, defer rendering aggregated images at the bottom while - // the response is still streaming. The images would otherwise overlap the pinned - // scrolling viewport. They are flushed once streaming completes. - if (this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete) { - this._pendingExternalResources.set(toolInvocation.toolCallId, toolInvocation); - return; - } - - const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); - if (extractedImages.length === 0) { - return; - } - - const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ - kind: 'data', - value: image.data.buffer, - mimeType: image.mimeType, - uri: image.uri, - })); - - this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); - } - - private flushPendingExternalResources(): void { - if (this._pendingExternalResources.size === 0) { - return; - } - const pending = Array.from(this._pendingExternalResources.values()); - this._pendingExternalResources.clear(); - for (const toolInvocation of pending) { - this.updateExternalResourceParts(toolInvocation); - } - } - - private appendItemToDOM( - content: HTMLElement, - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata, - originalParent?: HTMLElement - ): void { - if (!content.hasChildNodes() || content.textContent?.trim() === '') { - return; - } - - const itemWrapper = $('.chat-thinking-tool-wrapper'); - const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; - const isExternalEdit = toolInvocationOrMarkdown?.kind === 'externalEdit'; - const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const isSearchTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'search'; - const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; - - let icon: ThemeIcon; - if (isNoProblemsFoundResult(toolInvocationId, content.textContent ?? undefined)) { - icon = Codicon.search; - } else if (isMarkdownEdit || isExternalEdit) { - icon = Codicon.pencil; - } else if (isSearchTool) { - icon = Codicon.search; - } else if (isTerminalTool) { - const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number }; commandLine?: { isSandboxWrapped?: boolean } }; - const exitCode = terminalData?.terminalCommandState?.exitCode; - const isSandboxWrapped = terminalData?.commandLine?.isSandboxWrapped; - if (exitCode !== undefined && exitCode !== 0) { - icon = Codicon.error; - } else if (isSandboxWrapped) { - icon = Codicon.terminalSecure; - } else { - icon = toolInvocationIcon ?? Codicon.terminal; - } - } else if (content.classList.contains('chat-hook-outcome-blocked')) { - icon = Codicon.error; - } else if (content.classList.contains('chat-hook-outcome-warning')) { - icon = Codicon.warning; - } else { - icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon, content.textContent ?? undefined) : Codicon.tools; - } - - const iconElement = createThinkingIcon(icon); - itemWrapper.appendChild(iconElement); - itemWrapper.appendChild(content); - - if (this.toolInvocationCount === 1 && this.hookCount === 0 && originalParent) { - const toolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown : undefined; - this.singleItemInfo = { - element: content, - thinkingWrapper: itemWrapper, - originalParent, - originalNextSibling: this.domNode, - restoreToOriginalParent: !!toolInvocation || isExternalEdit, - toolInvocation - }; - } else { - this.singleItemInfo = undefined; - } - - const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); - if (isToolInvocation && toolInvocationOrMarkdown.toolCallId) { - this.toolWrappersByCallId.set(toolInvocationOrMarkdown.toolCallId, itemWrapper); - this.toolIconsByCallId.set(toolInvocationOrMarkdown.toolCallId, iconElement); - } - - this.appendToWrapper(itemWrapper); - - if (this.fixedScrollingMode && this.scrollableElement) { - // Observe the child wrapper for resizes (e.g. terminal expanding) - if (this.childResizeObserver && !this.streamingCompleted) { - const observeDisposable = this.childResizeObserver.observe(itemWrapper); - const toolCallId = isToolInvocation ? toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - let store = this.toolDisposables.get(toolCallId); - if (!store) { - store = new DisposableStore(); - this.toolDisposables.set(toolCallId, store); - } - store.add(observeDisposable); - } else { - this._register(observeDisposable); - } - } - - // Coalesce reads of scrollHeight to avoid forced reflows when many items - // are appended in the same tick (e.g. when restoring a session). - this.scheduleAppendRefresh(); - } - } - - private scheduleAppendRefresh(): void { - if (this._pendingAppendRefresh.value) { - return; - } - this._pendingAppendRefresh.value = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { - this._pendingAppendRefresh.clear(); - if (this._store.isDisposed) { - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - } - - private materializeLazyItem(item: ILazyItem): void { - if (item.kind === 'thinking') { - // Materialize thinking container - this.appendToWrapper(item.textContainer); - // Store reference to textContainer for updateThinking calls - this.textContainer = item.textContainer; - this.id = item.content.id; - // Use item.content which is kept up-to-date during streaming via updateThinking - this.updateThinking(item.content); - return; - } - - if (this.workingSpinnerLabel) { - const isTerminalTool = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && item.toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); - } - - // Handle tool items - if (item.lazy.hasValue) { - // Already evaluated — but may not have been placed in the DOM yet - // (e.g. finalizeTitleIfDefault materialized it before the wrapper existed). - const result = item.lazy.value; - if (!result.domNode.parentElement) { - this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); - } - return; - } - - const result = item.lazy.value; - this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); - - if (result.disposable) { - const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } - - // makes a new text container. when we update, we now update this container. - public setupThinkingContainer(content: IChatThinkingPart) { - // Avoid creating new containers after disposal - if (this._store.isDisposed) { - return; - } - this.appendedItemCount++; - this.allThinkingParts.push(content); - const contentText = extractTextFromPart(content); - this.recordReasoningContent(contentText); - // First-writer wins: a later grouped block can be the first multi-header - // summary (when earlier blocks had <2 headers), so track it here too — the - // lazy/reload path never routes through updateThinking. - this.trackDroppedSummaryHeader(contentText); - this.textContainer = $('.chat-thinking-item.markdown-content'); - // Observe the new textContainer for child resizes in fixed scrolling mode - if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { - this._register(this.childResizeObserver.observe(this.textContainer)); - } - if (content.value) { - // Use lazy rendering when collapsed to preserve order with tool items - if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { - // Render immediately when expanded - this.appendToWrapper(this.textContainer); - this.id = content.id; - this.updateThinking(content); - } else { - // Update this.content and this.id so that subsequent updateThinking calls - // or materializeLazyItem will use the correct content for this section - this.content = content; - this.id = content.id; - // Defer rendering until expanded to preserve order - const lazyThinking: ILazyThinkingItem = { - kind: 'thinking', - textContainer: this.textContainer, - content - }; - this.lazyItems.push(lazyThinking); - } - - if (this.workingSpinnerLabel) { - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); - } - } - this.updateDropdownClickability(); - } - - protected override setTitle(title: ChatThinkingTitle, omitPrefix?: boolean): void { - const titleValue = getThinkingTitleValue(title); - if (!titleValue || this.element.isComplete) { - return; - } - - if (omitPrefix) { - this.clearTitleDetail(); - if (this._collapseButton) { - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; - const plainSpan = $('span'); - plainSpan.textContent = titleValue; - labelElement.appendChild(plainSpan); - this._collapseButton.element.ariaLabel = titleValue; - } - this.forgetShimmerTitle(); - this.currentTitle = titleValue; - return; - } - - this.lastExtractedTitle = titleValue; - this.lastRenderedTitle = title; - this.currentTitle = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, titleValue); - - if (!this._collapseButton) { - return; - } - - const labelElement = this._collapseButton.labelElement; - - this.setShimmerTitle(localize('chat.thinking.shimmer', "{0}: ", this.defaultTitle)); - - // Dispose previous detail rendering - this._titleDetailRendered.clear(); - this._titleFileWidgetStore.clear(); - - const markdownTitle = typeof title === 'string' ? new MarkdownString(title) : title; - const result = this.chatContentMarkdownRenderer.render(markdownTitle); - result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); - this._titleFileWidgetStore.add(addDisposableListener(result.element, EventType.CLICK, event => { - if (isHTMLElement(event.target) && event.target.closest('a, input')) { - return; - } - EventHelper.stop(event, true); - this.toggleExpanded(); - })); - this._titleDetailRendered.value = result; - - const previousTitleDetail = this.titleDetailContainer; - // eslint-disable-next-line no-restricted-syntax - const hasTitleLinks = result.element.querySelector('a') !== null; - if (hasTitleLinks) { - const container = this._collapseButton.element.parentElement; - if (container) { - if (this._hoverChevron) { - container.appendChild(this._hoverChevron); - } - container.insertBefore(result.element, this.diffButton?.element ?? this._hoverChevron ?? null); - } - } else { - labelElement.appendChild(result.element); - if (!this.diffButton && this._hoverChevron) { - this._collapseButton.element.appendChild(this._hoverChevron); - } - } - previousTitleDetail?.remove(); - this.titleDetailContainer = result.element; - - const renderedTitle = result.element.textContent?.trim() || titleValue; - const thinkingLabel = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, renderedTitle); - this._collapseButton.element.ariaLabel = thinkingLabel; - this._collapseButton.element.ariaExpanded = String(this.isExpanded()); - } - - private clearTitleDetail(): void { - this.titleDetailContainer?.remove(); - this.titleDetailContainer = undefined; - this._titleDetailRendered.clear(); - this._titleFileWidgetStore.clear(); - } - - hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - - if (_element.isComplete) { - return true; - } - if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') - && other.toolSpecificData?.kind === 'subagent' - && !other.subAgentInvocationId) { - return false; - } - - if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { - return true; - } - - if (other.kind !== 'thinking') { - return false; - } - - return other?.id !== this.id; - } - - override dispose(): void { - this.isActive = false; - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - this.pendingRemovalFlushDisposable?.dispose(); - this.pendingRemovalFlushDisposable = undefined; - this.pendingScrollDisposable?.dispose(); - super.dispose(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, clearNode, DisposableResizeObserver, EventHelper, EventType, getWindow, hide, isHTMLElement, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js'; +import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../../../base/browser/ui/button/button.js'; +import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { IChatExternalEdit, IChatMarkdownContent, IChatTerminalToolInvocationData, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IChatContentPart, IChatContentPartDiffData, IChatContentPartDiffResource, IChatContentPartRenderContext } from './chatContentParts.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatConfiguration, ThinkingDisplayMode } from '../../../common/constants.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../../base/common/htmlContent.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; +import { basename, getComparisonKey } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ChatThinkingStyleContentPart, createThinkingIcon } from './chatThinkingStyleContentPart.js'; +export { createThinkingIcon }; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; +import { localize } from '../../../../../../nls.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; +import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; +import './media/chatThinkingContent.css'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { getCompactCodicon } from '../../chatIcons.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; +import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js'; +import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; + + +// Context key id mirrored from `vs/sessions/common/contextkeys` (`IsPhoneLayoutContext`). +// Inlined as a string because `vs/workbench` must not import from `vs/sessions`. +const SESSIONS_IS_PHONE_LAYOUT_KEY = 'sessionsIsPhoneLayout'; + +/** + * Read-only chats and phone layouts use collapsed preview regardless of the configured thinking style. + */ +export function getEffectiveThinkingDisplayMode(configurationService: IConfigurationService, contextKeyService: IContextKeyService, readOnly = false): ThinkingDisplayMode { + if (readOnly || contextKeyService.getContextKeyValue(SESSIONS_IS_PHONE_LAYOUT_KEY) === true) { + return ThinkingDisplayMode.CollapsedPreview; + } + return configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; +} + +function extractTextFromPart(content: IChatThinkingPart): string { + const raw = Array.isArray(content.value) ? content.value.join('') : (content.value || ''); + return raw.trim(); +} + +function isEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + return lowerToolId.includes('edit') || + lowerToolId.includes('create') || + lowerToolId.includes('replace') || + lowerToolId.includes('patch'); +} + +/** + * Returns true for edit tools whose generic display name should be replaced + * with "Editing files" while streaming (e.g. replace, multi-replace, patch, insertEdit). + * Excludes create and notebook tools which already have good labels. + */ +function isGenericEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + if (lowerToolId.includes('create') || lowerToolId.includes('notebook')) { + return false; + } + return lowerToolId.includes('replace') || + lowerToolId.includes('patch') || + lowerToolId.includes('insertedit') || + lowerToolId.includes('insert_edit') || + lowerToolId.includes('editfile'); +} + +function isProblemsToolId(toolId: string | undefined): boolean { + switch (toolId?.toLowerCase()) { + case 'problems': + case 'get_errors': + case 'copilot_geterrors': + return true; + default: + return false; + } +} + +function isNoProblemsFoundResult(toolId: string | undefined, resultText: string | undefined): boolean { + return isProblemsToolId(toolId) && resultText?.toLowerCase().includes('no problems found') === true; +} + +export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon, resultText?: string): ThemeIcon { + if (isNoProblemsFoundResult(toolId, resultText)) { + return Codicon.search; + } + + if (registeredIcon) { + return registeredIcon; + } + + const lowerToolId = toolId.toLowerCase(); + + if (lowerToolId.includes('comment')) { + return Codicon.comment; + } + + if ( + lowerToolId.includes('search') || + lowerToolId.includes('grep') || + lowerToolId.includes('find') || + lowerToolId.includes('list') || + lowerToolId.includes('semantic') || + lowerToolId.includes('changes') || + lowerToolId.includes('codebase') || + lowerToolId.includes('checked') + ) { + return Codicon.search; + } + + if ( + lowerToolId.includes('read') || + lowerToolId.includes('get_file') || + lowerToolId.includes('problems') + ) { + return Codicon.book; + } + + if (isEditToolId(toolId)) { + return Codicon.pencil; + } + + if ( + lowerToolId.includes('terminal') + ) { + return Codicon.terminal; + } + + // default to generic tool icon + return Codicon.tools; +} + +function setThinkingIcon(iconElement: HTMLElement, icon: ThemeIcon): void { + iconElement.className = 'chat-thinking-icon'; + iconElement.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); +} + +function extractTitleFromThinkingContent(content: string): string | undefined { + const headerMatch = content.match(/^\*\*([^*]+)\*\*/); + return headerMatch ? headerMatch[1] : undefined; +} + +/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ +function isThinkingHeaderLine(line: string): boolean { + return /^\s*\*\*.+\*\*\s*$/.test(line); +} + +/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ +function stripStandaloneBold(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { + return trimmed.slice(2, -2); + } + return text; +} + +/** + * Splits a reasoning-summary value into one markdown string per display row. + * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set + * and the value starts with a header, that header is dropped because it is + * surfaced as the collapsible title. Returns `undefined` unless the value has at + * least two header lines, so ordinary reasoning prose keeps single-block rendering. + */ +export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { + const sections: { isHeader: boolean; lines: string[] }[] = []; + for (const line of text.split('\n')) { + if (isThinkingHeaderLine(line)) { + sections.push({ isHeader: true, lines: [line] }); + } else if (sections.length === 0) { + sections.push({ isHeader: false, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + } + + if (sections.filter(section => section.isHeader).length < 2) { + return undefined; + } + + const dropFirst = dropLeadingHeader && sections[0].isHeader; + const rows: string[] = []; + sections.forEach((section, index) => { + const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; + const markdown = lines.join('\n').trim(); + if (markdown) { + rows.push(markdown); + } + }); + + return rows.length ? rows : undefined; +} + +type ChatThinkingTitle = string | IMarkdownString; + +function getThinkingTitleValue(title: ChatThinkingTitle): string { + return typeof title === 'string' ? title : title.value; +} + +function thinkingTitleEqual(first: ChatThinkingTitle, second: ChatThinkingTitle): boolean { + if (typeof first === 'string' || typeof second === 'string') { + return first === second; + } + return markdownStringEqual(first, second); +} + +/** + * Metadata passed to {@link ChatThinkingContentPart.appendItem} to drive + * title / icon extraction. The `kind` discriminates which payload is + * available; the thinking part inspects it to compute a label like + * "Edited foo.ts" without rendering the actual content itself (the + * factory provides the DOM). + */ +export type ChatThinkingItemMetadata = + | IChatToolInvocation + | IChatToolInvocationSerialized + | IChatMarkdownContent + | IChatExternalEdit; + +interface ILazyToolItem { + kind: 'tool'; + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + toolInvocationId?: string; + toolInvocationOrMarkdown?: ChatThinkingItemMetadata; + originalParent?: HTMLElement; + isHook?: boolean; +} + +interface ILazyThinkingItem { + kind: 'thinking'; + textContainer: HTMLElement; + content: IChatThinkingPart; +} + +type ILazyItem = ILazyToolItem | ILazyThinkingItem; +const THINKING_SCROLL_MAX_HEIGHT = 200; + +const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache'; +const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const TITLE_CACHE_MAX_ENTRIES = 1000; + +const enum WorkingMessageCategory { + Thinking = 'thinking', + Terminal = 'terminal', + Tool = 'tool' +} + +export const defaultThinkingMessages = [ + localize('chat.thinking.thinking.1', 'Thinking'), + localize('chat.thinking.thinking.2', 'Reasoning'), + localize('chat.thinking.thinking.3', 'Considering'), + localize('chat.thinking.thinking.4', 'Analyzing'), + localize('chat.thinking.thinking.5', 'Evaluating'), + localize('chat.thinking.thinking.6', 'Working'), +]; + +const terminalMessages = [ + localize('chat.thinking.terminal.1', 'Executing'), + localize('chat.thinking.terminal.2', 'Running'), + localize('chat.thinking.terminal.3', 'Processing'), +]; + +const toolMessages = [ + localize('chat.thinking.tool.1', 'Processing'), + localize('chat.thinking.tool.2', 'Preparing'), + localize('chat.thinking.tool.3', 'Loading'), + localize('chat.thinking.tool.4', 'Analyzing'), + localize('chat.thinking.tool.5', 'Evaluating'), +]; + +/** Easter-egg loading messages, used ~1 in {@link FUN_WORKING_MESSAGE_RATE} picks. */ +const funWorkingMessages = [ + // Generic + localize('chat.working.fun.1', "Bribing the hamster"), + localize('chat.working.fun.2', "Reticulating splines"), + localize('chat.working.fun.3', "Untangling the spaghetti"), + localize('chat.working.fun.4', "Communing with the codebase"), + localize('chat.working.fun.5', "Letting it cook"), + localize('chat.working.fun.6', "Thanking all the fish"), + localize('chat.working.fun.7', "Stabilizing the wormhole"), + localize('chat.working.fun.8', "Baking the ideas"), + + // Code + localize('chat.working.fun.code.1', "Consulting the oracle"), + localize('chat.working.fun.code.2', "Shooting for the stars"), + localize('chat.working.fun.code.3', "Stirring the solution"), + + // Minecraft + localize('chat.working.fun.minecraft.1', "Mining diamonds"), + localize('chat.working.fun.minecraft.2', "Digging straight down"), + localize('chat.working.fun.minecraft.3', "Mining at night"), + + // Microsoft + localize('chat.working.fun.ms.1', "Summoning Clippy"), +]; + +const FUN_WORKING_MESSAGE_RATE = 50; + +type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; + +function getCustomThinkingPhrases(configurationService: IConfigurationService): { customPhrases: string[]; replaceDefaults: boolean } { + const config = configurationService.getValue(ChatConfiguration.ThinkingPhrases); + const customPhrases = Array.isArray(config?.phrases) + ? config.phrases + .filter((phrase): phrase is string => typeof phrase === 'string') + .map(phrase => phrase.trim()) + .filter(phrase => phrase.length > 0) + : []; + + return { + customPhrases, + replaceDefaults: config?.mode === 'replace' && customPhrases.length > 0, + }; +} + +/** Returns an easter-egg message ~1 in {@link FUN_WORKING_MESSAGE_RATE}, else `undefined`. */ +export function maybePickFunWorkingMessage(configurationService: IConfigurationService, random = Math.random): string | undefined { + if (getCustomThinkingPhrases(configurationService).replaceDefaults) { + return undefined; + } + + if (Math.floor(random() * FUN_WORKING_MESSAGE_RATE) === 0) { + return funWorkingMessages[Math.floor(random() * funWorkingMessages.length)]; + } + return undefined; +} + +/** + * Builds a phrase pool from defaults and user-configured custom phrases. + * In 'replace' mode, only custom phrases are used; in 'append' mode (default), + * custom phrases are added to the defaults. + */ +export function buildPhrasePool(defaults: string[], configurationService: IConfigurationService): string[] { + const { customPhrases, replaceDefaults } = getCustomThinkingPhrases(configurationService); + + if (customPhrases.length > 0) { + return replaceDefaults ? [...customPhrases] : [...defaults, ...customPhrases]; + } + return [...defaults]; +} + +export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implements IChatContentPart { + + private static _codeBlockRendererSync(_languageId: string, text: string, _raw?: string): HTMLElement { + const codeElement = $('code'); + codeElement.textContent = text; + return codeElement; + } + + public readonly codeblocks: undefined; + public readonly codeblocksPartId: undefined; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + private readonly _asyncRenderCallback = () => this._onDidChangeHeight.fire(); + + private id: string | undefined; + private content: IChatThinkingPart; + private currentThinkingValue: string; + private currentTitle: string; + private defaultTitle = localize('chat.thinking.header', 'Thinking'); + private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); + private textContainer!: HTMLElement; + private readonly _markdownResult = this._register(new MutableDisposable()); + private summaryRowItems: HTMLElement[] = []; + private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; + private summaryRowTexts: string[] = []; + private droppedSummaryHeader: string | undefined; + private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; + private wrapper!: HTMLElement; + private fixedScrollingMode: boolean = false; + private readonly thinkingDisplayMode: ThinkingDisplayMode; + private autoScrollEnabled: boolean = true; + private scrollableElement: DomScrollableElement | undefined; + private lastExtractedTitle: string | undefined; + private extractedTitles: string[] = []; + private toolInvocationCount: number = 0; + private appendedItemCount: number = 0; + private isActive: boolean = true; + private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; + private allThinkingParts: IChatThinkingPart[] = []; + private hookCount: number = 0; + private singleItemInfo: { element: HTMLElement; thinkingWrapper: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null; restoreToOriginalParent: boolean; toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized } | undefined; + private lazyItems: ILazyItem[] = []; + private hasExpandedOnce: boolean = false; + private workingSpinnerElement: HTMLElement | undefined; + private workingSpinnerLabel: HTMLElement | undefined; + private availableMessagesByCategory = new Map(); + private readonly toolWrappersByCallId = new Map(); + private readonly toolIconsByCallId = new Map(); + private readonly toolLabelsByCallId = new Map(); + private readonly toolDisposables = this._register(new DisposableMap()); + private readonly ownedToolParts = new Map(); + private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; + private pendingRemovalFlushDisposable: IDisposable | undefined; + private pendingScrollDisposable: IDisposable | undefined; + private wrapperResizeObserverDisposable: IDisposable | undefined; + private childResizeObserver: DisposableResizeObserver | undefined; + private isUpdatingDimensions: boolean = false; + private lastKnownContentHeight: number = 0; + private lastKnownScrollTop: number = 0; + private titleDetailContainer: HTMLElement | undefined; + private lastRenderedTitle: ChatThinkingTitle | undefined; + private collapsedTitleBeforeExpansion: ChatThinkingTitle | undefined; + private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; + private readonly _pendingExternalResources = new Map(); + private readonly _titleDetailRendered = this._register(new MutableDisposable()); + private readonly _pendingAppendRefresh = this._register(new MutableDisposable()); + private readonly diffDataByPartId = new Map(); + private _aggregatedDiff: IEditSessionDiffStats = { added: 0, removed: 0 }; + private readonly diffButtonStore = this._register(new DisposableStore()); + private diffButton: Button | undefined; + private containsReasoning: boolean; + private containsGroupedItems: boolean = false; + private reasoningDurationMs: number | undefined; + + get aggregatedDiff(): IEditSessionDiffStats { return this._aggregatedDiff; } + + private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { + const fun = maybePickFunWorkingMessage(this.configurationService); + if (fun) { + return fun; + } + + let pool = this.availableMessagesByCategory.get(category); + if (!pool || pool.length === 0) { + let defaults: string[]; + switch (category) { + case WorkingMessageCategory.Thinking: + defaults = defaultThinkingMessages; + break; + case WorkingMessageCategory.Terminal: + defaults = terminalMessages; + break; + case WorkingMessageCategory.Tool: + default: + defaults = toolMessages; + break; + } + + pool = buildPhrasePool(defaults, this.configurationService); + + this.availableMessagesByCategory.set(category, pool); + } + const index = Math.floor(Math.random() * pool.length); + return pool.splice(index, 1)[0]; + } + + constructor( + content: IChatThinkingPart, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + private streamingCompleted: boolean, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IHoverService hoverService: IHoverService, + @ITelemetryService telemetryService: ITelemetryService, + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService private readonly editorService: IEditorService, + ) { + const initialText = extractTextFromPart(content); + const containsReasoning = initialText.trim().length > 0; + const extractedTitle = extractTitleFromThinkingContent(initialText) + ?? localize('chat.thinking.header.initial', 'Thinking'); + + super(extractedTitle, context, undefined, hoverService, configurationService, telemetryService); + + this.containsReasoning = containsReasoning; + this.reasoningDurationMs = content.reasoningDurationMs; + this.id = content.id; + this.content = content; + this.allThinkingParts.push(content); + const configuredMode = getEffectiveThinkingDisplayMode(this.configurationService, contextKeyService, context.readOnly); + this.thinkingDisplayMode = configuredMode; + + this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; + + this.currentTitle = extractedTitle; + if (extractedTitle !== this.defaultTitle) { + this.lastExtractedTitle = extractedTitle; + this.extractedTitles.push(extractedTitle); + } + this.currentThinkingValue = initialText; + this.trackDroppedSummaryHeader(initialText); + + if (initialText.trim()) { + this.appendedItemCount++; + } + + // Alert screen reader users that thinking has started + if (this.configurationService.getValue(AccessibilityWorkbenchSettingId.VerboseChatProgressUpdates)) { + alert(localize('chat.thinking.started', 'Thinking')); + } + + if (configuredMode === ThinkingDisplayMode.Collapsed) { + this.setExpanded(false); + } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { + // Start expanded if still in progress. + // streamingCompleted is true when look-ahead finds subsequent non-pinnable + // parts, meaning this thinking part won't receive more content. + this.setExpanded(!this.streamingCompleted && !this.element.isComplete); + } else { + this.setExpanded(false); + } + + const node = this.domNode; + if (this._hoverChevron) { + this._register(addDisposableListener(this._hoverChevron, EventType.CLICK, event => { + EventHelper.stop(event, true); + this.toggleExpanded(); + })); + } + + this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); + this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + node.appendChild(this._externalResourceWidget.domNode); + + if (!this.streamingCompleted && !this.element.isComplete) { + if (!this.fixedScrollingMode) { + node.classList.add('chat-thinking-active'); + } + } + + if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { + this.setShimmerTitle(extractedTitle); + } + + if (this.fixedScrollingMode) { + node.classList.add('chat-thinking-fixed-mode'); + this.currentTitle = this.defaultTitle; + } + + this._register(toDisposable(() => { + for (const d of this.ownedToolParts.values()) { + d.dispose(); + } + this.ownedToolParts.clear(); + })); + + this._register(toDisposable(() => { + for (const result of this.summaryRowResults) { + result?.dispose(); + } + for (const result of this.retiredSummaryRowResults) { + result.dispose(); + } + })); + + this._register(autorun(r => { + const isExpanded = this._isExpanded.read(r); + // Materialize lazy items when first expanded + if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { + this.hasExpandedOnce = true; + // Flush pending removals so that completed hidden tools are removed from lazyItems before materialization + this.processPendingRemovals(); + for (const item of this.lazyItems) { + this.materializeLazyItem(item); + } + } + + // If expanded but content matches title and there's nothing else to show, revert immediately. + // Skip this check while still streaming — more content will arrive. + if (isExpanded && !this.shouldAllowExpansion() && (this.streamingCompleted || this.element.isComplete)) { + this.setExpanded(false); + return; + } + + this._externalResourceWidget.setCollapsed(!isExpanded); + + // Fire when expanded/collapsed + this._onDidChangeHeight.fire(); + })); + + const label = this.lastExtractedTitle ?? ''; + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(label); + } + + if (this._collapseButton) { + this._register(this._collapseButton.onDidClick(() => { + if (this.fixedScrollingMode) { + if (this.streamingCompleted) { + this.domNode.classList.add('chat-thinking-fixed-mode-animated'); + } + return; + } + + if (this.streamingCompleted) { + return; + } + + const expanded = this.isExpanded(); + if (expanded) { + // Just expanded: show plain 'Working' with no detail + this.collapsedTitleBeforeExpansion = this.lastRenderedTitle ?? this.lastExtractedTitle; + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } else { + // Restore the title that was visible before expansion. Tool state + // updates can become less descriptive while the section is open. + const collapsedTitle = this.collapsedTitleBeforeExpansion ?? this.lastRenderedTitle ?? this.lastExtractedTitle; + this.collapsedTitleBeforeExpansion = undefined; + if (collapsedTitle) { + this.setTitle(collapsedTitle); + } else { + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } + } + })); + } + } + + protected override shouldInitEarly(): boolean { + return this.fixedScrollingMode && !this.streamingCompleted; + } + + protected override shouldAnimateContent(): boolean { + return !this.fixedScrollingMode; + } + + protected override shouldPrepareContentAnimation(): boolean { + return !this.fixedScrollingMode; + } + + protected override contentDidInitialize(): void { + if (this.fixedScrollingMode && this.streamingCompleted && this.scrollableElement) { + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = '0px'; + scrollableDomNode.getBoundingClientRect(); + } + } + + protected override get collapsibleKind(): string { + return 'thinking'; + } + + protected override expansionDidChange(expanded: boolean): void { + if (this.fixedScrollingMode && this.streamingCompleted) { + if (expanded) { + this.syncDimensionsAndScheduleScroll(); + } else { + this.updateCompletedScrollAnimationState(false); + } + } + } + + // @TODO: @justschen Convert to template for each setting? + protected override getThinkingIcon(_active: boolean, expanded: boolean): ThemeIcon { + if (this.streamingCompleted || this.element.isComplete) { + return Codicon.checkCompact; + } + return !this.fixedScrollingMode && expanded ? Codicon.chevronDownCompact : Codicon.circleFilledCompact; + } + + protected override initContent(): HTMLElement { + this.wrapper = this.createThinkingBody(); + if (!this.streamingCompleted) { + this.wrapper.classList.add('chat-thinking-streaming'); + } + + // Only create textContainer here if there's no pending lazy thinking item. + // If there's a lazy thinking item, it will be rendered via materializeLazyItem + // with the latest streaming content. + const hasLazyThinkingItems = this.lazyItems.some(item => item.kind === 'thinking'); + if (this.currentThinkingValue && !hasLazyThinkingItems) { + this.textContainer = $('.chat-thinking-item.markdown-content'); + this.wrapper.appendChild(this.textContainer); + this.renderMarkdown(this.currentThinkingValue); + } + + if (!this.streamingCompleted && !this.element.isComplete) { + const spinner = this.createThinkingSpinnerRow(this.getRandomWorkingMessage(WorkingMessageCategory.Thinking)); + this.workingSpinnerElement = spinner.row; + this.workingSpinnerLabel = spinner.label; + this.wrapper.appendChild(spinner.row); + this.updateWorkingSpinnerVisibility(); + } + + // wrap content in scrollable element for fixed scrolling mode + if (this.fixedScrollingMode) { + this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + handleMouseWheel: true, + alwaysConsumeMouseWheel: false + })); + this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop))); + + let pendingMutationRefresh: IDisposable | undefined; + const mutationObserver = new MutationObserver(() => { + if (pendingMutationRefresh) { + return; + } + pendingMutationRefresh = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { + pendingMutationRefresh = undefined; + if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + }); + mutationObserver.observe(this.wrapper, { childList: true, subtree: true }); + this._register({ + dispose: () => { + mutationObserver.disconnect(); + pendingMutationRefresh?.dispose(); + } + }); + + // Observe child elements for resizes (e.g. terminal output growing) + // so we can update scroll dimensions when the wrapper box is pinned at max-height. + this.childResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.child', () => { + if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { + return; + } + + this.syncDimensionsAndScheduleScroll(); + })); + if (this.textContainer) { + this._register(this.childResizeObserver.observe(this.textContainer)); + } + if (this.workingSpinnerElement) { + this._register(this.childResizeObserver.observe(this.workingSpinnerElement)); + } + + // Cache wrapper scrollHeight post-layout via ResizeObserver to avoid forced reflows. + const wrapperResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.wrapper', (entries) => { + if (entries[0]) { + this.lastKnownContentHeight = this.wrapper.scrollHeight; + if (this.streamingCompleted && this.isExpanded()) { + this.updateScrollDimensionsForCompletion(); + } else if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { + this.updateScrollDimensionsFromCache(); + } + } + })); + this.wrapperResizeObserverDisposable = this._register(wrapperResizeObserver.observe(this.wrapper)); + + // Once content exceeds max-height, the wrapper box size stops changing + // so ResizeObserver won't fire. Fall back to scrollHeight reads here. + this._register(this._onDidChangeHeight.event(() => { + if (!this.streamingCompleted && this.wrapperResizeObserverDisposable) { + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + return; + } + this.syncDimensionsAndScheduleScroll(); + })); + + this.syncDimensionsAndScheduleScroll(); + + this.updateDropdownClickability(); + return this.scrollableElement.getDomNode(); + } + + this.updateDropdownClickability(); + return this.wrapper; + } + + private handleScroll(scrollTop: number): void { + if (!this.scrollableElement || this.isUpdatingDimensions) { + return; + } + + this.lastKnownScrollTop = scrollTop; + const contentHeight = this.lastKnownContentHeight; + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = contentHeight - viewportHeight; + this.autoScrollEnabled = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; + + this.updateFadeClasses(scrollTop, contentHeight, viewportHeight); + } + + private updateFadeClasses(scrollTop?: number, contentHeight?: number, viewportHeight?: number): void { + if (!this.fixedScrollingMode || this.streamingCompleted) { + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + return; + } + + const currentScrollTop = scrollTop ?? this.lastKnownScrollTop; + const currentContentHeight = contentHeight ?? this.lastKnownContentHeight; + const currentViewportHeight = viewportHeight ?? Math.min(currentContentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = currentContentHeight - currentViewportHeight; + + this.domNode.classList.toggle('chat-thinking-fade-top', currentScrollTop > 5); + this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5); + } + + // Fallback for non-ResizeObserver updates (onDidChangeHeight, initial setup). + private syncDimensionsAndScheduleScroll(): void { + if (this.pendingScrollDisposable) { + return; + } + this.pendingScrollDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { + this.pendingScrollDisposable = undefined; + if (this._store.isDisposed) { + return; + } + if (this.streamingCompleted) { + this.updateScrollDimensionsForCompletion(); + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + } + + /** + * Re-read scrollHeight from the DOM and update cached height if changed. + */ + private refreshContentHeight(): void { + if (!this.wrapper || !this.scrollableElement) { + return; + } + const newHeight = this.wrapper.scrollHeight; + if (newHeight && newHeight !== this.lastKnownContentHeight) { + this.lastKnownContentHeight = newHeight; + } + } + + private updateScrollDimensionsFromCache(): void { + if (!this.scrollableElement || this._store.isDisposed) { + return; + } + + const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed'); + if (!isCollapsed) { + return; + } + + const contentHeight = this.lastKnownContentHeight; + if (!contentHeight) { + return; + } + + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + this.isUpdatingDimensions = true; + try { + const viewportWidth = this.scrollableElement.getDomNode().clientWidth; + this.scrollableElement.setScrollDimensions({ + width: viewportWidth, + scrollWidth: viewportWidth, + height: viewportHeight, + scrollHeight: contentHeight + }); + + if (this.autoScrollEnabled) { + this.scrollToBottom(contentHeight); + } + } finally { + this.isUpdatingDimensions = false; + } + + this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight); + this.updateDropdownClickability(contentHeight); + } + + private scrollToBottom(contentHeight: number): void { + if (!this.scrollableElement) { + return; + } + + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + if (contentHeight > viewportHeight) { + const newScrollTop = contentHeight - viewportHeight; + this.lastKnownScrollTop = newScrollTop; + // Prevent reveal-on-scroll behavior from interfering with explicit bottom pinning. + this.scrollableElement.setRevealOnScroll(false); + this.scrollableElement.setScrollPosition({ scrollTop: newScrollTop }); + this.scrollableElement.setRevealOnScroll(true); + } + } + + /** + * updates scroll dimensions when streaming is complete. + */ + private updateScrollDimensionsForCompletion(): void { + if (!this.scrollableElement || !this.fixedScrollingMode) { + return; + } + + const contentHeight = this.wrapper.scrollHeight; + this.lastKnownContentHeight = contentHeight; + + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = `${contentHeight}px`; + const viewportWidth = scrollableDomNode.clientWidth; + this.scrollableElement.setScrollDimensions({ + width: viewportWidth, + scrollWidth: viewportWidth, + height: contentHeight, + scrollHeight: contentHeight + }); + this.lastKnownScrollTop = 0; + this.scrollableElement.setRevealOnScroll(false); + this.scrollableElement.setScrollPosition({ scrollTop: 0 }); + this.scrollableElement.setRevealOnScroll(true); + this.updateCompletedScrollAnimationState(this.isExpanded()); + } + + private updateCompletedScrollAnimationState(expanded: boolean): void { + if (!this.scrollableElement) { + return; + } + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = expanded ? `${this.lastKnownContentHeight}px` : '0px'; + scrollableDomNode.inert = !expanded; + } + + private renderMarkdown(content: string, reuseExisting?: boolean): void { + // Guard against rendering after disposal to avoid leaking disposables + if (this._store.isDisposed) { + return; + } + + // A later thinking part reassigns textContainer; retire stale row tracking + // so the predecessor's rendered rows stay frozen while this part renders. + if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { + this.retireSummaryRows(); + } + + const cleanedContent = content.trim(); + if (!cleanedContent) { + this._markdownResult.clear(); + this.clearSummaryRows(); + if (this.textContainer) { + clearNode(this.textContainer); + } + return; + } + + // Multi-header reasoning summaries render each header section as its own + // row so the dropdown reads as a list. Sibling rows need an attached container so their + // insertion isn't a no-op, so a detached (lazy) container falls through to + // single-block rendering until it is materialized. A block drops its leading + // header only when that header is the tracked title owner, so a grouped block + // never drops a header that isn't surfaced as the title. + const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; + const summaryRows = splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); + if (summaryRows && this.textContainer?.parentNode) { + this.renderSummaryRows(summaryRows); + return; + } + this.clearSummaryRows(); + + // If the entire content is bolded, strip the bold markers for rendering + const contentToRender = stripStandaloneBold(cleanedContent); + + const target = reuseExisting ? this._markdownResult.value?.element : undefined; + + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(contentToRender), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, target); + this._markdownResult.value = rendered; + if (!target) { + if (this.textContainer) { + clearNode(this.textContainer); + this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); + this.textContainer.appendChild(rendered.element); + } + } + } + + /** Renders one summary row, reusing the row's element while its text only grows. */ + private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { + const previous = this.summaryRowResults[index]; + const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); + // A standalone header renders as plain text, not bold. + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, reuse ? previous?.element : undefined); + if (!reuse) { + clearNode(container); + container.appendChild(createThinkingIcon(Codicon.circleFilled)); + container.appendChild(rendered.element); + } + previous?.dispose(); + this.summaryRowResults[index] = rendered; + this.summaryRowTexts[index] = markdown; + } + + private renderSummaryRows(rows: string[]): void { + // Rows own the DOM in this mode; release the single-block renderer. + this._markdownResult.clear(); + + for (let i = 0; i < rows.length; i++) { + let container = this.summaryRowItems[i]; + if (!container) { + container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); + this.summaryRowItems[i] = container; + this.summaryRowTexts[i] = ''; + if (i === 0) { + clearNode(container); + } else { + this.summaryRowItems[i - 1].after(container); + } + } + if (this.summaryRowTexts[i] !== rows[i]) { + this.renderSummaryRow(container, i, rows[i]); + } + } + + // Streaming only appends, but guard against a shrinking row set on re-render. + for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { + this.summaryRowResults[i]?.dispose(); + if (this.summaryRowItems[i] !== this.textContainer) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems.length = rows.length; + this.summaryRowResults.length = rows.length; + this.summaryRowTexts.length = rows.length; + } + + /** Removes the extra summary rows and resets tracking, keeping the text container. */ + private clearSummaryRows(): void { + if (!this.summaryRowItems.length) { + return; + } + for (let i = 0; i < this.summaryRowItems.length; i++) { + this.summaryRowResults[i]?.dispose(); + if (i !== 0) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ + private retireSummaryRows(): void { + for (const result of this.summaryRowResults) { + if (result) { + this.retiredSummaryRowResults.push(result); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** + * Records the leading header the primary summary block drops, derived from content + * so it is available at finalize even when the rows never lazily rendered (the + * collapsed-through-completion flow). First-writer wins: the first grouped block + * that is a multi-header summary owns the title, and only that header is dropped. + */ + private trackDroppedSummaryHeader(value: string): void { + if (this.droppedSummaryHeader) { + return; + } + const trimmed = value.trim(); + if (splitReasoningSummaryRows(trimmed, true)) { + this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); + if (this.fixedScrollingMode && this.droppedSummaryHeader && this.currentTitle !== this.droppedSummaryHeader) { + this.setTitle(this.droppedSummaryHeader); + } + } + } + + private setFinalizedTitle(title: string): void { + if (!this._collapseButton) { + return; + } + + const displayTitle = this.getFinalizedDisplayTitle(title); + this.clearTitleDetail(); + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.forgetShimmerTitle(); + + const firstSpaceIndex = displayTitle.indexOf(' '); + if (firstSpaceIndex === -1) { + // Single word title, no need to split + labelElement.textContent = displayTitle; + } else { + const verb = displayTitle.substring(0, firstSpaceIndex); + const rest = displayTitle.substring(firstSpaceIndex); + + const verbSpan = $('span'); + verbSpan.textContent = verb; + labelElement.appendChild(verbSpan); + + const restSpan = $('span.chat-thinking-title-detail-text'); + restSpan.textContent = rest; + labelElement.appendChild(restSpan); + } + + // Show aggregated diff stats from edit pills (only when there are actual changes) + if (this.diffDataByPartId.size > 0) { + const { added, removed } = this._aggregatedDiff; + if (added > 0 || removed > 0) { + this.renderDiffButton(added, removed); + + const insertionsFragment = added === 1 ? localize('chat.thinking.insertions.one', "1 insertion") : localize('chat.thinking.insertions', "{0} insertions", added); + const deletionsFragment = removed === 1 ? localize('chat.thinking.deletions.one', "1 deletion") : localize('chat.thinking.deletions', "{0} deletions", removed); + this.setAriaLabel(localize('chat.thinking.titleWithDiff', "{0}, {1}, {2}", displayTitle, insertionsFragment, deletionsFragment)); + } else { + this.clearDiffButton(); + this.setAriaLabel(displayTitle); + } + } else { + this.clearDiffButton(); + this.setAriaLabel(displayTitle); + } + } + + private renderDiffButton(added: number, removed: number): void { + const resources = this.getAggregatedDiffResources(); + if (resources.length === 0) { + this.clearDiffButton(); + return; + } + + if (!this.diffButton) { + const collapseButton = this._collapseButton; + const container = collapseButton?.element.parentElement; + if (!container) { + return; + } + + collapseButton.element.classList.add('chat-thinking-title-with-diff'); + const button = this.diffButtonStore.add(new Button(container, {})); + button.element.classList.add('chat-thinking-title-diff'); + this.diffButtonStore.add(button.onDidClick(event => { + EventHelper.stop(event, true); + this.openDiffs(); + })); + this.diffButtonStore.add(this.hoverService.setupDelayedHover(button.element, { + content: localize('chat.thinking.viewChanges', "View File Changes"), + style: HoverStyle.Pointer, + })); + this.diffButton = button; + + if (this._hoverChevron) { + container.appendChild(this._hoverChevron); + } + } + + this.diffButton.element.replaceChildren( + $('span.label-added', {}, `+${added}`), + $('span.label-removed', {}, `-${removed}`), + ); + this.diffButton.setAriaLabel(localize( + 'chat.thinking.viewChangesAccessible', + 'View file changes, {0} lines added, {1} lines deleted', + added, + removed, + )); + } + + private clearDiffButton(): void { + this.diffButtonStore.clear(); + this.diffButton = undefined; + const collapseButton = this._collapseButton; + collapseButton?.element.classList.remove('chat-thinking-title-with-diff'); + const container = collapseButton?.element.parentElement; + if (collapseButton && container && this._hoverChevron) { + if (this.titleDetailContainer?.parentElement === container) { + container.appendChild(this._hoverChevron); + } else { + collapseButton.element.appendChild(this._hoverChevron); + } + } + } + + private getAggregatedDiffResources(): IChatContentPartDiffResource[] { + const result = new Map(); + + for (const data of this.diffDataByPartId.values()) { + for (const resource of data.resources) { + const key = getComparisonKey(resource.resource); + const existing = result.get(key); + if (existing) { + existing.resource = resource.resource; + existing.modifiedURI = resource.modifiedURI; + } else { + result.set(key, { ...resource }); + } + } + } + + return [...result.values()].filter(resource => resource.originalURI !== undefined || resource.modifiedURI !== undefined); + } + + private openDiffs(): void { + const resources = this.getAggregatedDiffResources(); + if (resources.length === 0) { + return; + } + + const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); + this.editorService.openEditor({ + multiDiffSource: source, + label: localize('chat.thinking.changes.title', "Section File Changes"), + resources: resources.map(resource => ({ + original: { resource: resource.originalURI }, + modified: { resource: resource.modifiedURI }, + goToFileResource: resource.resource, + })), + }); + } + + private getFinalizedDisplayTitle(title: string): string { + if (this.thinkingDisplayMode !== ThinkingDisplayMode.Collapsed || !this.containsReasoning || this.containsGroupedItems || !this.reasoningDurationMs) { + return title; + } + + const seconds = Math.ceil(this.reasoningDurationMs / 1000); + const duration = localize('chat.thinking.duration.seconds', "{0}s", seconds); + return localize('chat.thinking.titleWithDuration', "{0} - {1}", title, duration); + } + + public hasReasoningContent(): boolean { + return this.containsReasoning; + } + + public hasGroupedItems(): boolean { + return this.containsGroupedItems; + } + + private recordReasoningContent(content: string): void { + if (!content.trim()) { + return; + } + this.containsReasoning = true; + } + + private setDropdownClickable(clickable: boolean): void { + if (this._collapseButton) { + this._collapseButton.element.style.pointerEvents = clickable ? 'auto' : 'none'; + } + + if (!clickable && this.streamingCompleted) { + this.setFinalizedTitle(this.lastExtractedTitle ?? this.currentTitle); + } + } + + private shouldAllowExpansion(): boolean { + // Multiple tool invocations or lazy items mean there's content to show + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { + return true; + } + + // Count meaningful children in the wrapper (exclude the working spinner) + if (this.wrapper) { + const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; + if (meaningfulChildren > 1) { + return true; + } + } + + const contentWithoutTitle = this.currentThinkingValue.trim(); + const titleToCompare = this.lastExtractedTitle ?? this.currentTitle; + + const stripMarkdown = (text: string) => { + return text + .replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1').trim(); + }; + + const strippedContent = stripMarkdown(contentWithoutTitle); + // If content is empty or matches the title exactly, nothing to expand + return !(!strippedContent || strippedContent === titleToCompare); + } + + private updateDropdownClickability(knownContentHeight?: number): void { + let allowExpansion = this.shouldAllowExpansion(); + + // don't allow feedback on fixed scrolling before reaching max height. + if (allowExpansion && this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this.wrapper) { + // Use only the cached height — never read scrollHeight here to avoid forced reflows. + // If the cache is empty, conservatively disallow expansion; the ResizeObserver + // will populate lastKnownContentHeight and trigger another call once layout settles. + const contentHeight = knownContentHeight ?? this.lastKnownContentHeight; + if (!contentHeight || contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { + allowExpansion = false; + } + } + + if (!allowExpansion && this.isExpanded() && (this.streamingCompleted || this.element.isComplete)) { + this.setExpanded(false); + } + this.setDropdownClickable(allowExpansion); + } + + private appendToWrapper(element: HTMLElement): void { + if (!this.wrapper) { + return; + } + if (this.workingSpinnerElement && this.workingSpinnerElement.parentNode === this.wrapper) { + this.wrapper.insertBefore(element, this.workingSpinnerElement); + } else { + this.wrapper.appendChild(element); + } + } + + private updateWorkingSpinnerVisibility(reader?: IReader): void { + if (!this.wrapper || !this.workingSpinnerElement) { + return; + } + + const hasRunningTerminalTool = this.toolInvocations.some(toolInvocation => { + const terminalData = toolInvocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; + if (terminalData?.kind !== 'terminal' || terminalData.terminalCommandState?.exitCode !== undefined) { + return false; + } + + return !IChatToolInvocation.isComplete(toolInvocation, reader); + }); + + const isAttached = this.workingSpinnerElement.parentNode === this.wrapper; + if (hasRunningTerminalTool && isAttached) { + this.workingSpinnerElement.remove(); + this._onDidChangeHeight.fire(); + } else if (!hasRunningTerminalTool && !isAttached && !this.streamingCompleted && !this.element.isComplete) { + this.wrapper.appendChild(this.workingSpinnerElement); + this._onDidChangeHeight.fire(); + } + } + + public resetId(): void { + this.id = undefined; + } + + public collapseContent(): void { + this.setExpanded(false); + } + + public updateThinking(content: IChatThinkingPart): void { + // If disposed, ignore late updates coming from renderer diffing + if (this._store.isDisposed) { + return; + } + this.content = content; + this.reasoningDurationMs = content.reasoningDurationMs; + + // Update any pending lazy thinking item with matching ID so that + // when materialized, it will have the latest streaming content + for (const lazyItem of this.lazyItems) { + if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) { + lazyItem.content = content; + break; + } + } + + const raw = extractTextFromPart(content); + this.recordReasoningContent(raw); + const next = raw; + if (next === this.currentThinkingValue) { + return; + } + const previousValue = this.currentThinkingValue; + const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); + this.currentThinkingValue = next; + this.trackDroppedSummaryHeader(next); + this.renderMarkdown(next, reuseExisting); + + if (this.fixedScrollingMode && this.scrollableElement) { + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + } + + const extractedTitle = extractTitleFromThinkingContent(raw); + if (extractedTitle && extractedTitle !== this.currentTitle) { + if (!this.extractedTitles.includes(extractedTitle)) { + this.extractedTitles.push(extractedTitle); + } + this.lastExtractedTitle = extractedTitle; + } + + if (!extractedTitle || extractedTitle === this.currentTitle) { + return; + } + + const label = this.lastExtractedTitle ?? ''; + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(label); + } + + this.updateDropdownClickability(); + } + + public getIsActive(): boolean { + return this.isActive; + } + + /** + * Returns true when this thinking part has no meaningful content to display: + * no tool invocations, no lazy items, no hooks, and no thinking text. + * This happens when a tool is removed from thinking (e.g. due to confirmation) + * and the thinking part was only created to hold that tool. + */ + public isEffectivelyEmpty(): boolean { + this.processPendingRemovals(); + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0 || this.hookCount > 0) { + return false; + } + if (this.currentThinkingValue.trim().length > 0) { + return false; + } + return true; + } + + public markAsInactive(): void { + this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + this.processPendingRemovals(); + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + + // Clear the attached-to-thinking flag on all tool invocations + for (const toolInvocation of this.toolInvocations) { + toolInvocation.isAttachedToThinking = false; + } + } + + public finalizeTitleIfDefault(): void { + this.processPendingRemovals(); + + // With lazy rendering, wrapper may not be created yet if content hasn't been expanded + if (this.wrapper) { + this.wrapper.classList.remove('chat-thinking-streaming'); + } + this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + this.streamingCompleted = true; + this.setContentAnimationEnabled(!this.fixedScrollingMode); + + // Now that streaming is complete, render any aggregated images that were + // deferred while scrolling was pinned in fixed scrolling mode. + this.flushPendingExternalResources(); + + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + + if (this._collapseButton) { + this._collapseButton.icon = Codicon.checkCompact; + } + + // Update scroll dimensions now that streaming is complete + // This removes unnecessary scrollbar when content fits + this.updateScrollDimensionsForCompletion(); + + this.updateDropdownClickability(); + + // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. + if (this.droppedSummaryHeader) { + this.currentTitle = this.droppedSummaryHeader; + this.content.generatedTitle = this.droppedSummaryHeader; + this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); + this.setFinalizedTitle(this.droppedSummaryHeader); + return; + } + + if (this.content.generatedTitle) { + this.currentTitle = this.content.generatedTitle; + this.setGeneratedTitleOnAllParts(this.content.generatedTitle); + this.setFinalizedTitle(this.content.generatedTitle); + return; + } + + // Reuse any existing generated title from tool invocations or thinking parts. + const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle + ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; + if (existingTitle) { + this.currentTitle = existingTitle; + this.content.generatedTitle = existingTitle; + this.setGeneratedTitleOnAllParts(existingTitle); + this.setFinalizedTitle(existingTitle); + return; + } + + // Only check the persisted cache when re-rendering (tool invocations are + // serialized), not during live streaming. Reasoning-only blocks (no tools) + // are keyed off the stable thinking part id so their generated headers are + // also restored on reload (non-local sessions only). + const allToolsSerialized = this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized'); + if (allToolsSerialized && !LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const cacheId = this.getTitleCacheId(); + if (cacheId) { + const cachedTitle = this.getCachedTitle(cacheId); + if (cachedTitle) { + this.currentTitle = cachedTitle; + this.content.generatedTitle = cachedTitle; + this.setGeneratedTitleOnAllParts(cachedTitle); + this.setFinalizedTitle(cachedTitle); + return; + } + } + } + + // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position + if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') { + // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now + if (!this.singleItemInfo) { + const lazyItem = this.lazyItems.find(item => item.kind === 'tool' && item.originalParent); + if (lazyItem && lazyItem.kind === 'tool') { + const toolInvocation = lazyItem.toolInvocationOrMarkdown && (lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? lazyItem.toolInvocationOrMarkdown : undefined; + const result = lazyItem.lazy.value; + this.appendItemToDOM(result.domNode, lazyItem.toolInvocationId, lazyItem.toolInvocationOrMarkdown, lazyItem.originalParent); + if (result.disposable) { + const toolCallId = toolInvocation?.toolCallId; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } + } + if (this.singleItemInfo && this.restoreSingleItemToOriginalPosition()) { + return; + } + } + + // if exactly one actual extracted title and no tool invocations, use that as the final title. + if (this.extractedTitles.length === 1 && this.toolInvocationCount === 0) { + const title = this.extractedTitles[0]; + this.currentTitle = title; + this.content.generatedTitle = title; + this.setGeneratedTitleOnAllParts(title); + this.setFinalizedTitle(title); + return; + } + + const generateTitles = this.configurationService.getValue(ChatConfiguration.ThinkingGenerateTitles) ?? true; + if (!generateTitles) { + this.setFallbackTitle(); + return; + } + + this.generateTitleViaLLM(); + } + + private setGeneratedTitleOnAllParts(title: string): void { + for (const toolInvocation of this.toolInvocations) { + toolInvocation.generatedTitle = title; + } + for (const thinkingPart of this.allThinkingParts) { + thinkingPart.generatedTitle = title; + } + } + + private loadTitleCache(): Record { + return this.storageService.getObject>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {}; + } + + private saveTitleCache(cache: Record): void { + if (Object.keys(cache).length === 0) { + this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE); + } + } + + private getTitleCacheKey(id: string): string { + return `${chatSessionResourceToId(this.element.sessionResource)}:${id}`; + } + + /** + * Stable id used to persist/restore the generated title. Tool-based blocks + * key off the last tool call id; reasoning-only blocks fall back to the + * thinking part id so their headers also survive a session reload. + */ + private getTitleCacheId(): string | undefined { + const lastTool = this.toolInvocations[this.toolInvocations.length - 1]; + if (lastTool) { + return lastTool.toolCallId; + } + return this.allThinkingParts.find(t => t.id)?.id ?? this.content.id; + } + + private getCachedTitle(id: string): string | undefined { + const entry = this.loadTitleCache()[this.getTitleCacheKey(id)]; + if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) { + return undefined; + } + return entry.title; + } + + private setCachedTitle(id: string, title: string): void { + const cache = this.loadTitleCache(); + const now = Date.now(); + + // Evict expired entries on write + for (const key of Object.keys(cache)) { + if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) { + delete cache[key]; + } + } + + cache[this.getTitleCacheKey(id)] = { title, storedAt: now }; + + // Cap size by dropping oldest entries + const keys = Object.keys(cache); + if (keys.length > TITLE_CACHE_MAX_ENTRIES) { + const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt); + for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) { + delete cache[sorted[i]]; + } + } + + this.saveTitleCache(cache); + } + + private async generateTitleViaLLM(): Promise { + const cts = new CancellationTokenSource(); + const timeout = setTimeout(() => cts.cancel(), 5000); + + try { + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); + if (!models.length) { + this.setFallbackTitle(); + return; + } + + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + + let context: string; + if (this.extractedTitles.length > 0) { + context = this.extractedTitles.join(', '); + } else { + context = this.currentThinkingValue.substring(0, 1000); + } + + const prompt = `Summarize the following content in a SINGLE sentence (under 10 words) using past tense. Follow these rules strictly: + + OUTPUT FORMAT: + - MUST be a single sentence + - MUST be under 10 words + - The FIRST word MUST be a past tense verb (e.g. "Updated", "Reviewed", "Created", "Searched", "Analyzed") + - No quotes, no trailing punctuation + + GENERAL: + - The content may include tool invocations (file edits, reads, searches, terminal commands), reasoning headers, or raw thinking text + - For reasoning headers or thinking text (no tool calls), summarize WHAT was considered/analyzed, NOT that thinking occurred + - For thinking-only summaries, use phrases like: "Considered...", "Planned...", "Analyzed...", "Reviewed..." + + TOOL NAME FILTERING: + - NEVER include tool names like "Replace String in File", "Multi Replace String in File", "Create File", "Read File", etc. in the output + - If an action says "Edited X and used Replace String in File", output ONLY the action on X + - Tool names describe HOW something was done, not WHAT was done - always omit them + + VOCABULARY - Use varied synonyms for natural-sounding summaries: + - For edits: "Updated", "Modified", "Changed", "Refactored", "Fixed", "Adjusted" + - For reads: "Reviewed", "Examined", "Checked", "Inspected", "Analyzed", "Explored" + - For creates: "Created", "Added", "Generated" + - For searches: "Searched for", "Looked up", "Investigated" + - For terminal: "Ran command", "Executed" + - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" + - Choose the synonym that best fits the context + +${this.hookCount > 0 ? `BLOCKED/DENIED CONTENT (hooks detected): + - Only mention "blocked" if the content explicitly includes hook results that blocked or warned about a tool (e.g. "Blocked terminal" or "Warning for read_file") + - If blocked items are present alongside normal tool calls, briefly note the block but do NOT let it dominate the summary: e.g. "Updated file.ts, blocked terminal" + + ` : `IMPORTANT: Do NOT use words like "blocked", "denied", or "tried" in the summary - there are no hooks or blocked items in this content. Just summarize normally. + + `}RULES FOR TOOL CALLS: + 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " + 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) + 3. If exactly ONE file was read: Start with a read synonym + "" (include actual filename) + 4. If MULTIPLE files were edited: Start with an edit synonym + "X files" + 5. If MULTIPLE files were read: Start with a read synonym + "X files" + 6. If BOTH edits AND reads occurred on DIFFERENT files: Combine them naturally + 7. For searches: Say "searched for " or "looked up " with the actual search term, NOT "searched for files" + 8. After the file info, you may add a brief summary of other actions if space permits + 9. NEVER say "1 file" - always use the actual filename when there's only one file + + RULES FOR REASONING HEADERS (no tool calls): + 1. If the input contains reasoning/analysis headers without actual tool invocations, summarize the main topic and what was considered + 2. Use past tense verbs that indicate thinking, not doing: "Considered", "Planned", "Analyzed", "Evaluated" + 3. Focus on WHAT was being thought about, not that thinking occurred + + RULES FOR RAW THINKING TEXT: + 1. Extract the main topic or question being considered from the text + 2. Identify any specific files, functions, or concepts mentioned + 3. Summarize as "Analyzed " or "Considered " + 4. If discussing code structure: "Reviewed " + 5. If discussing a problem: "Analyzed " + 6. If discussing implementation: "Planned " + + EXAMPLES WITH TOOLS: + - "Read HomePage.tsx, Edited HomePage.tsx" → "Reviewed and updated HomePage.tsx" + - "Edited HomePage.tsx" → "Updated HomePage.tsx" + - "Edited config.css and used Replace String in File" → "Modified config.css" + - "Edited App.tsx, used Multi Replace String in File" → "Refactored App.tsx" + - "Read config.json, Read package.json" → "Reviewed 2 files" + - "Edited App.tsx, Read utils.ts" → "Updated App.tsx and checked utils.ts" + - "Edited App.tsx, Read utils.ts, Read types.ts" → "Updated App.tsx and reviewed 2 files" + - "Edited index.ts, Edited styles.css, Ran terminal command" → "Modified 2 files and ran command" + - "Read README.md, Searched for AuthService" → "Checked README.md and searched for AuthService" + - "Searched for login, Searched for authentication" → "Searched for login and authentication" + - "Edited api.ts, Edited models.ts, Read schema.json" → "Updated 2 files and reviewed schema.json" + - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" + - "Searched codebase for error handling" → "Looked up error handling" + +${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): + - "Blocked terminal, Edited config.ts" → "Edited config.ts, terminal was blocked" + - "Blocked terminal, Blocked read_file" → "Two tools were blocked by hooks" + - "Warning for read_file, Edited utils.ts" → "Edited utils.ts with a hook warning" + + ` : ''}EXAMPLES WITH REASONING HEADERS (no tools): + - "Analyzing component architecture" → "Considered component architecture" + - "Planning refactor strategy" → "Planned refactor strategy" + - "Reviewing error handling approach, Considering edge cases" → "Analyzed error handling approach" + - "Understanding the codebase structure" → "Reviewed codebase structure" + - "Thinking about implementation options" → "Considered implementation options" + + EXAMPLES WITH RAW THINKING TEXT: + - "I need to understand how the authentication flow works in this app..." → "Analyzed authentication flow" + - "Let me think about how to refactor this component to be more maintainable..." → "Planned component refactoring" + - "The error seems to be coming from the database connection..." → "Investigated database connection issue" + - "Looking at the UserService class, I see it handles..." → "Reviewed UserService implementation" + + Content: ${context}`; + + const response = await this.languageModelsService.sendChatRequest( + models[0], + undefined, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], + {}, + cts.token + ); + + let generatedTitle = ''; + for await (const part of response.stream) { + if (cts.token.isCancellationRequested) { + break; + } + if (Array.isArray(part)) { + for (const p of part) { + if (p.type === 'text') { + generatedTitle += p.value; + } + } + } else if (part.type === 'text') { + generatedTitle += part.value; + } + } + + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + + await response.result; + generatedTitle = generatedTitle.trim(); + + if (generatedTitle.includes('can\'t assist with that')) { + this.setFallbackTitle(); + return; + } + + if (generatedTitle && !this._store.isDisposed) { + this.currentTitle = generatedTitle; + this.setFinalizedTitle(generatedTitle); + this.content.generatedTitle = generatedTitle; + this.setGeneratedTitleOnAllParts(generatedTitle); + + // Persist to storage for non-local sessions only + if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const cacheId = this.getTitleCacheId(); + if (cacheId) { + this.setCachedTitle(cacheId, generatedTitle); + } + } + + return; + } + } catch (error) { + // fall through to default title + } finally { + clearTimeout(timeout); + cts.dispose(); + } + + this.setFallbackTitle(); + } + + private restoreSingleItemToOriginalPosition(): boolean { + if (!this.singleItemInfo) { + return false; + } + + const { element, thinkingWrapper, originalParent, originalNextSibling, restoreToOriginalParent, toolInvocation } = this.singleItemInfo; + + const hasOtherThinkingItems = this.wrapper && Array.from(this.wrapper.children).some(child => + child !== thinkingWrapper && child !== this.workingSpinnerElement + ); + if (hasOtherThinkingItems) { + this.singleItemInfo = undefined; + return false; + } + + const precedingToolInvocationPart = isHTMLElement(originalNextSibling) && originalNextSibling.parentElement === originalParent + ? originalNextSibling.previousElementSibling + : originalParent.lastElementChild; + if (restoreToOriginalParent) { + if (originalNextSibling && originalNextSibling.parentNode === originalParent) { + originalParent.insertBefore(element, originalNextSibling); + } else { + originalParent.appendChild(element); + } + } else if (precedingToolInvocationPart?.classList.contains('chat-tool-invocation-part')) { + precedingToolInvocationPart.appendChild(element); + } else if (originalNextSibling && originalNextSibling.parentNode === originalParent) { + originalParent.insertBefore(element, originalNextSibling); + } else { + originalParent.appendChild(element); + } + thinkingWrapper.remove(); + + if (toolInvocation) { + this.toolWrappersByCallId.delete(toolInvocation.toolCallId); + this.toolIconsByCallId.delete(toolInvocation.toolCallId); + toolInvocation.isAttachedToThinking = false; + } + + hide(this.domNode); + this.singleItemInfo = undefined; + return true; + } + + private updateAggregatedDiff(): void { + let totalAdded = 0; + let totalRemoved = 0; + for (const data of this.diffDataByPartId.values()) { + totalAdded += data.added; + totalRemoved += data.removed; + } + this._aggregatedDiff = { added: totalAdded, removed: totalRemoved }; + + // Re-render the finalized title if streaming is already complete, + // since diff events from edit pills may arrive after the title was set. + if (this.streamingCompleted || this.element.isComplete) { + this.setFinalizedTitle(this.currentTitle); + } + } + + private setFallbackTitle(): void { + const finalLabel = this.appendedItemCount > 0 + ? this.appendedItemCount === 1 + ? localize('chat.thinking.finished.withStepsSingular', 'Finished with 1 step') + : localize('chat.thinking.finished.withStepsPlural', 'Finished with {0} steps', this.appendedItemCount) + : localize('chat.thinking.finished', 'Finished Working'); + + this.currentTitle = finalLabel; + // With lazy rendering, wrapper may not be created yet if content hasn't been expanded + if (this.wrapper) { + this.wrapper.classList.remove('chat-thinking-streaming'); + } + this.domNode.classList.remove('chat-thinking-active'); + this.streamingCompleted = true; + + // Render any aggregated images that were deferred during fixed scrolling streaming. + this.flushPendingExternalResources(); + + if (this._collapseButton) { + this._collapseButton.icon = Codicon.checkCompact; + this.setFinalizedTitle(finalLabel); + } + + this.updateDropdownClickability(); + } + + /** + * Appends a tool invocation or content item to the thinking group. + * The factory is called lazily - only when the thinking section is expanded. + * If already expanded, the factory is called immediately. + * + * When the caller has already created the content part eagerly (for example, a + * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass + * that part as `eagerDisposable` so it is registered on this thinking part + * immediately. Otherwise, if the thinking section is collapsed and the lazy item + * is never materialized (because the user never expands it), the eagerly-created + * part would leak: its disposable is only referenced from inside the factory's + * closure, which nothing ever calls. + */ + public appendItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata, + originalParent?: HTMLElement, + onDidChangeDiff?: Event, + eagerDisposable?: IDisposable, + ): void { + this.processPendingRemovals(); + this.containsGroupedItems = true; + + // Track tool invocation metadata immediately (for title generation) + this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); + this.updateWorkingSpinnerVisibility(); + this.appendedItemCount++; + + // Listen for diff changes from edit pills + if (onDidChangeDiff && toolInvocationId) { + this.diffDataByPartId.set(toolInvocationId, { added: 0, removed: 0, resources: [] }); + this._register(onDidChangeDiff(data => { + this.diffDataByPartId.set(toolInvocationId, data); + this.updateAggregatedDiff(); + })); + } + + // Register any caller-owned disposable up-front so it is always cleaned up + // with this thinking part, even if the lazy item is never materialized. + if (eagerDisposable) { + this._register(eagerDisposable); + } + + // get random message based on tool type + if (this.workingSpinnerLabel) { + const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); + } + + // If expanded or has been expanded once, render immediately + if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { + const result = factory(); + this.appendItemToDOM(result.domNode, toolInvocationId, toolInvocationOrMarkdown, originalParent); + if (result.disposable) { + const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } else { + // Defer rendering until expanded + const item: ILazyToolItem = { + kind: 'tool', + lazy: new Lazy(factory), + toolInvocationId, + toolInvocationOrMarkdown, + originalParent, + isHook: !toolInvocationOrMarkdown && !!toolInvocationId, + }; + this.lazyItems.push(item); + } + + this.updateDropdownClickability(); + } + + public removeMaterializedItem(toolCallId: string): void { + this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.delete(toolCallId); + + const wrapper = this.toolWrappersByCallId.get(toolCallId); + if (wrapper) { + this.toolWrappersByCallId.delete(toolCallId); + this.toolIconsByCallId.delete(toolCallId); + } + + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); + + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId + ); + if (toolInvocationsIndex !== -1) { + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const label = this.toolLabelsByCallId.get(toolCallId); + if (label) { + const titleIndex = this.extractedTitles.indexOf(label); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + } + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + this.toolLabelsByCallId.delete(toolCallId); + + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + + this.updateWorkingSpinnerVisibility(); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + + /** + * Removes a markdown edit pill child by its part ID (codeblocksPartId). + */ + public removeEditPillByPartId(partId: string): void { + let removed = false; + + const lazyIndex = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === partId); + if (lazyIndex !== -1) { + this.lazyItems.splice(lazyIndex, 1); + removed = true; + } + + if (this.diffDataByPartId.delete(partId)) { + this.updateAggregatedDiff(); + removed = true; + } + + if (removed) { + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + } + + /** + * removes/re-establishes a lazy item from the thinking container + * this is needed so we can check if there are confirmations still needed + */ + public removeLazyItem(toolInvocationId: string): boolean { + const index = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === toolInvocationId); + if (index === -1) { + return false; + } + + const removedItem = this.lazyItems[index]; + this.lazyItems.splice(index, 1); + this.appendedItemCount--; + if (removedItem.kind === 'tool' && removedItem.isHook) { + this.hookCount = Math.max(0, this.hookCount - 1); + } else { + this.toolInvocationCount--; + } + + // Clear the attached-to-thinking flag on the removed tool invocation + if (removedItem.kind === 'tool' && removedItem.toolInvocationOrMarkdown && (removedItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { + removedItem.toolInvocationOrMarkdown.isAttachedToThinking = false; + + // Keep extractedTitles in sync when a lazy tool leaves the thinking container. + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + const label = this.toolLabelsByCallId.get(toolCallId); + if (label) { + const titleIndex = this.extractedTitles.indexOf(label); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + } + this.toolLabelsByCallId.delete(toolCallId); + } + + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolId === toolInvocationId + ); + if (toolInvocationsIndex !== -1) { + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + + this.updateDropdownClickability(); + this.updateWorkingSpinnerVisibility(); + return true; + } + + private processPendingRemovals(): void { + this.pendingRemovalFlushDisposable?.dispose(); + this.pendingRemovalFlushDisposable = undefined; + + if (this.pendingRemovals.length === 0) { + return; + } + + const pendingRemovals = this.pendingRemovals; + this.pendingRemovals = []; + + for (const pending of pendingRemovals) { + this.removeStreamingToolEntry(pending.toolCallId, pending.toolLabel); + } + } + + private schedulePendingRemovalsFlush(): void { + if (this.pendingRemovalFlushDisposable) { + return; + } + + this.pendingRemovalFlushDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { + this.pendingRemovalFlushDisposable = undefined; + if (this._store.isDisposed) { + return; + } + + this.processPendingRemovals(); + }); + } + + // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. + private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { + this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.get(toolCallId)?.dispose(); + this.ownedToolParts.delete(toolCallId); + + const wrapper = this.toolWrappersByCallId.get(toolCallId); + if (wrapper) { + wrapper.remove(); + this.toolWrappersByCallId.delete(toolCallId); + this.toolIconsByCallId.delete(toolCallId); + } + + // make sure to remove any lazy item as well + const lazyIndex = this.lazyItems.findIndex(item => + item.kind === 'tool' && + item.toolInvocationOrMarkdown && + (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && + item.toolInvocationOrMarkdown.toolCallId === toolCallId + ); + if (lazyIndex !== -1) { + const removedLazyItem = this.lazyItems[lazyIndex]; + if (removedLazyItem.kind === 'tool' && removedLazyItem.toolInvocationOrMarkdown && (removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { + removedLazyItem.toolInvocationOrMarkdown.isAttachedToThinking = false; + } + this.lazyItems.splice(lazyIndex, 1); + } + + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId + ); + if (toolInvocationsIndex !== -1) { + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + + const titleIndex = this.extractedTitles.indexOf(toolLabel); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + this.toolLabelsByCallId.delete(toolCallId); + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + this.updateWorkingSpinnerVisibility(); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + + private trackToolMetadata( + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata + ): void { + if (!toolInvocationId) { + return; + } + + // Track hooks separately: if toolInvocationOrMarkdown is undefined, it's a hook item + const isHook = !toolInvocationOrMarkdown; + if (isHook) { + this.hookCount++; + } else { + this.toolInvocationCount++; + } + + // Shift default title from 'Thinking' to 'Working' once we have tool calls + if (this.toolInvocationCount === 1) { + this.defaultTitle = this.workingTitle; + } + + let toolCallLabel: string; + let toolCallTitle: ChatThinkingTitle; + + const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); + if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { + const invocationMessage = toolInvocationOrMarkdown.invocationMessage; + + // For edit-type tools that are still streaming, use a friendlier label + // instead of the generic tool display name (e.g. "Replace String in File") + const isStreamingEditTool = toolInvocationOrMarkdown.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocationOrMarkdown) && isGenericEditToolId(toolInvocationOrMarkdown.toolId); + if (isStreamingEditTool) { + toolCallTitle = localize('chat.thinking.editingFiles', 'Editing files'); + } else { + toolCallTitle = invocationMessage; + } + toolCallLabel = getThinkingTitleValue(toolCallTitle); + + this.toolInvocations.push(toolInvocationOrMarkdown); + + // Track the displayed label for consistent cleanup + const toolCallId = toolInvocationOrMarkdown.toolCallId; + this.toolLabelsByCallId.set(toolCallId, toolCallLabel); + + // Render external image pills for serialized (already-completed) tool invocations + if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + + // Queue hidden serialized tools for removal immediately. + if (IChatToolInvocation.isEffectivelyHidden(toolInvocationOrMarkdown)) { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: toolCallLabel }); + this.schedulePendingRemovalsFlush(); + } + } + + // track state for live/still streaming tools, excluding serialized tools + if (toolInvocationOrMarkdown.kind === 'toolInvocation') { + let currentToolLabel = toolCallLabel; + let isComplete = false; + let isStreaming = IChatToolInvocation.isStreaming(toolInvocationOrMarkdown); + + const toolStore = new DisposableStore(); + this.toolDisposables.set(toolInvocationOrMarkdown.toolCallId, toolStore); + + const updateTitle = (updatedTitle: ChatThinkingTitle) => { + const updatedMessage = getThinkingTitleValue(updatedTitle); + if (updatedMessage && !thinkingTitleEqual(updatedTitle, toolCallTitle)) { + // replace old title if exists, otherwise add new + if (updatedMessage !== currentToolLabel) { + const oldIndex = this.extractedTitles.indexOf(currentToolLabel); + const updatedIndex = this.extractedTitles.indexOf(updatedMessage); + + if (oldIndex !== -1) { + if (updatedIndex !== -1 && updatedIndex !== oldIndex) { + this.extractedTitles.splice(oldIndex, 1); + } else { + this.extractedTitles[oldIndex] = updatedMessage; + } + } else if (updatedIndex === -1) { + this.extractedTitles.push(updatedMessage); + } + currentToolLabel = updatedMessage; + } + toolCallLabel = updatedMessage; + toolCallTitle = updatedTitle; + this.toolLabelsByCallId.set(toolCallId, updatedMessage); + this.lastExtractedTitle = updatedMessage; + + // make sure not to set title if expanded + if (!this.fixedScrollingMode && !this._isExpanded.read(undefined)) { + this.setTitle(updatedTitle); + } + } + }; + + const autorunDisposable = autorun(reader => { + if (isComplete) { + return; + } + + const currentState = toolInvocationOrMarkdown.state.read(reader); + this.updateWorkingSpinnerVisibility(reader); + + // queue item to be removed if it was streaming and presentation is hidden + if (isStreaming && currentState.type !== IChatToolInvocation.StateKind.Streaming) { + isStreaming = false; + + // Update terminal tool icon based on sandbox wrapping state + const termData = toolInvocationOrMarkdown.toolSpecificData as IChatTerminalToolInvocationData | undefined; + if (termData?.kind === 'terminal') { + const iconEl = this.toolIconsByCallId.get(toolCallId); + if (iconEl) { + const newIcon = termData.commandLine?.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal; + setThinkingIcon(iconEl, newIcon); + } + } + + if (toolInvocationOrMarkdown.presentation === 'hidden') { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); + this.schedulePendingRemovalsFlush(); + isComplete = true; + return; + } + } + + if (currentState.type === IChatToolInvocation.StateKind.Completed || + currentState.type === IChatToolInvocation.StateKind.Cancelled) { + // Remove tools that should be hidden now or after completion. + if (toolInvocationOrMarkdown.presentation === 'hidden' || toolInvocationOrMarkdown.presentation === 'hiddenAfterComplete') { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); + this.schedulePendingRemovalsFlush(); + } + + // Render image pills outside the collapsible area for completed tools + if (currentState.type === IChatToolInvocation.StateKind.Completed) { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + const completedMessage = toolInvocationOrMarkdown.pastTenseMessage ?? toolInvocationOrMarkdown.invocationMessage; + const completedText = typeof completedMessage === 'string' ? completedMessage : completedMessage.value; + const iconElement = this.toolIconsByCallId.get(toolCallId); + if (iconElement && isNoProblemsFoundResult(toolInvocationOrMarkdown.toolId, completedText)) { + setThinkingIcon(iconElement, Codicon.search); + } + } + + isComplete = true; + return; + } + + // streaming + if (currentState.type === IChatToolInvocation.StateKind.Streaming) { + isStreaming = true; + const streamingMessage = currentState.streamingMessage.read(reader); + if (streamingMessage) { + updateTitle(streamingMessage); + } + return; + } + + // executing (something like `Replacing 67 lines.....`) + if (currentState.type === IChatToolInvocation.StateKind.Executing) { + const progressData = currentState.progress.read(reader); + if (progressData.message) { + updateTitle(progressData.message); + } else { + const invocationMsg = toolInvocationOrMarkdown.invocationMessage; + if (invocationMsg) { + updateTitle(invocationMsg); + } + } + return; + } + + // confirmations, failures, completed, other, etc + const invocationMsg = toolInvocationOrMarkdown.invocationMessage; + if (invocationMsg) { + updateTitle(invocationMsg); + } + }); + toolStore.add(autorunDisposable); + } + } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { + const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); + if (codeblockInfo?.uri) { + const filename = basename(codeblockInfo.uri); + toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); + } else { + toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); + } + toolCallTitle = toolCallLabel; + } else if (toolInvocationOrMarkdown?.kind === 'externalEdit') { + const filename = basename(toolInvocationOrMarkdown.uri); + switch (toolInvocationOrMarkdown.editKind) { + case 'create': + toolCallLabel = localize('chat.thinking.createdFile', 'Created {0}', filename); + break; + case 'delete': + toolCallLabel = localize('chat.thinking.deletedFile', 'Deleted {0}', filename); + break; + case 'rename': + toolCallLabel = localize('chat.thinking.renamedFile', 'Renamed {0}', filename); + break; + case 'edit': + toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); + break; + } + toolCallTitle = toolCallLabel; + } else { + toolCallLabel = toolInvocationId; + toolCallTitle = toolCallLabel; + } + + // Add tool call to extracted titles for LLM title generation + if (!this.extractedTitles.includes(toolCallLabel)) { + this.extractedTitles.push(toolCallLabel); + } + + this.lastExtractedTitle = toolCallLabel; + + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(toolCallTitle); + } + } + + private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolSpecificData?.kind === 'terminal') { + return; + } + + // In fixed scrolling mode, defer rendering aggregated images at the bottom while + // the response is still streaming. The images would otherwise overlap the pinned + // scrolling viewport. They are flushed once streaming completes. + if (this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete) { + this._pendingExternalResources.set(toolInvocation.toolCallId, toolInvocation); + return; + } + + const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); + if (extractedImages.length === 0) { + return; + } + + const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ + kind: 'data', + value: image.data.buffer, + mimeType: image.mimeType, + uri: image.uri, + })); + + this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); + } + + private flushPendingExternalResources(): void { + if (this._pendingExternalResources.size === 0) { + return; + } + const pending = Array.from(this._pendingExternalResources.values()); + this._pendingExternalResources.clear(); + for (const toolInvocation of pending) { + this.updateExternalResourceParts(toolInvocation); + } + } + + private appendItemToDOM( + content: HTMLElement, + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata, + originalParent?: HTMLElement + ): void { + if (!content.hasChildNodes() || content.textContent?.trim() === '') { + return; + } + + const itemWrapper = $('.chat-thinking-tool-wrapper'); + const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; + const isExternalEdit = toolInvocationOrMarkdown?.kind === 'externalEdit'; + const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const isSearchTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'search'; + const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; + + let icon: ThemeIcon; + if (isNoProblemsFoundResult(toolInvocationId, content.textContent ?? undefined)) { + icon = Codicon.search; + } else if (isMarkdownEdit || isExternalEdit) { + icon = Codicon.pencil; + } else if (isSearchTool) { + icon = Codicon.search; + } else if (isTerminalTool) { + const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number }; commandLine?: { isSandboxWrapped?: boolean } }; + const exitCode = terminalData?.terminalCommandState?.exitCode; + const isSandboxWrapped = terminalData?.commandLine?.isSandboxWrapped; + if (exitCode !== undefined && exitCode !== 0) { + icon = Codicon.error; + } else if (isSandboxWrapped) { + icon = Codicon.terminalSecure; + } else { + icon = toolInvocationIcon ?? Codicon.terminal; + } + } else if (content.classList.contains('chat-hook-outcome-blocked')) { + icon = Codicon.error; + } else if (content.classList.contains('chat-hook-outcome-warning')) { + icon = Codicon.warning; + } else { + icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon, content.textContent ?? undefined) : Codicon.tools; + } + + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + itemWrapper.appendChild(content); + + if (this.toolInvocationCount === 1 && this.hookCount === 0 && originalParent) { + const toolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown : undefined; + this.singleItemInfo = { + element: content, + thinkingWrapper: itemWrapper, + originalParent, + originalNextSibling: this.domNode, + restoreToOriginalParent: !!toolInvocation || isExternalEdit, + toolInvocation + }; + } else { + this.singleItemInfo = undefined; + } + + const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); + if (isToolInvocation && toolInvocationOrMarkdown.toolCallId) { + this.toolWrappersByCallId.set(toolInvocationOrMarkdown.toolCallId, itemWrapper); + this.toolIconsByCallId.set(toolInvocationOrMarkdown.toolCallId, iconElement); + } + + this.appendToWrapper(itemWrapper); + + if (this.fixedScrollingMode && this.scrollableElement) { + // Observe the child wrapper for resizes (e.g. terminal expanding) + if (this.childResizeObserver && !this.streamingCompleted) { + const observeDisposable = this.childResizeObserver.observe(itemWrapper); + const toolCallId = isToolInvocation ? toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + let store = this.toolDisposables.get(toolCallId); + if (!store) { + store = new DisposableStore(); + this.toolDisposables.set(toolCallId, store); + } + store.add(observeDisposable); + } else { + this._register(observeDisposable); + } + } + + // Coalesce reads of scrollHeight to avoid forced reflows when many items + // are appended in the same tick (e.g. when restoring a session). + this.scheduleAppendRefresh(); + } + } + + private scheduleAppendRefresh(): void { + if (this._pendingAppendRefresh.value) { + return; + } + this._pendingAppendRefresh.value = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { + this._pendingAppendRefresh.clear(); + if (this._store.isDisposed) { + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + } + + private materializeLazyItem(item: ILazyItem): void { + if (item.kind === 'thinking') { + // Materialize thinking container + this.appendToWrapper(item.textContainer); + // Store reference to textContainer for updateThinking calls + this.textContainer = item.textContainer; + this.id = item.content.id; + // Use item.content which is kept up-to-date during streaming via updateThinking + this.updateThinking(item.content); + return; + } + + if (this.workingSpinnerLabel) { + const isTerminalTool = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && item.toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); + } + + // Handle tool items + if (item.lazy.hasValue) { + // Already evaluated — but may not have been placed in the DOM yet + // (e.g. finalizeTitleIfDefault materialized it before the wrapper existed). + const result = item.lazy.value; + if (!result.domNode.parentElement) { + this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); + } + return; + } + + const result = item.lazy.value; + this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); + + if (result.disposable) { + const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } + + // makes a new text container. when we update, we now update this container. + public setupThinkingContainer(content: IChatThinkingPart) { + // Avoid creating new containers after disposal + if (this._store.isDisposed) { + return; + } + this.appendedItemCount++; + this.allThinkingParts.push(content); + const contentText = extractTextFromPart(content); + this.recordReasoningContent(contentText); + // First-writer wins: a later grouped block can be the first multi-header + // summary (when earlier blocks had <2 headers), so track it here too — the + // lazy/reload path never routes through updateThinking. + this.trackDroppedSummaryHeader(contentText); + this.textContainer = $('.chat-thinking-item.markdown-content'); + // Observe the new textContainer for child resizes in fixed scrolling mode + if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { + this._register(this.childResizeObserver.observe(this.textContainer)); + } + if (content.value) { + // Use lazy rendering when collapsed to preserve order with tool items + if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { + // Render immediately when expanded + this.appendToWrapper(this.textContainer); + this.id = content.id; + this.updateThinking(content); + } else { + // Update this.content and this.id so that subsequent updateThinking calls + // or materializeLazyItem will use the correct content for this section + this.content = content; + this.id = content.id; + // Defer rendering until expanded to preserve order + const lazyThinking: ILazyThinkingItem = { + kind: 'thinking', + textContainer: this.textContainer, + content + }; + this.lazyItems.push(lazyThinking); + } + + if (this.workingSpinnerLabel) { + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); + } + } + this.updateDropdownClickability(); + } + + protected override setTitle(title: ChatThinkingTitle, omitPrefix?: boolean): void { + const titleValue = getThinkingTitleValue(title); + if (!titleValue || this.element.isComplete) { + return; + } + + if (omitPrefix) { + this.clearTitleDetail(); + if (this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const plainSpan = $('span'); + plainSpan.textContent = titleValue; + labelElement.appendChild(plainSpan); + this._collapseButton.element.ariaLabel = titleValue; + } + this.forgetShimmerTitle(); + this.currentTitle = titleValue; + return; + } + + this.lastExtractedTitle = titleValue; + this.lastRenderedTitle = title; + this.currentTitle = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, titleValue); + + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + + this.setShimmerTitle(localize('chat.thinking.shimmer', "{0}: ", this.defaultTitle)); + + // Dispose previous detail rendering + this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); + + const markdownTitle = typeof title === 'string' ? new MarkdownString(title) : title; + const result = this.chatContentMarkdownRenderer.render(markdownTitle); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); + this._titleFileWidgetStore.add(addDisposableListener(result.element, EventType.CLICK, event => { + if (isHTMLElement(event.target) && event.target.closest('a, input')) { + return; + } + EventHelper.stop(event, true); + this.toggleExpanded(); + })); + this._titleDetailRendered.value = result; + + const previousTitleDetail = this.titleDetailContainer; + // eslint-disable-next-line no-restricted-syntax + const hasTitleLinks = result.element.querySelector('a') !== null; + if (hasTitleLinks) { + const container = this._collapseButton.element.parentElement; + if (container) { + if (this._hoverChevron) { + container.appendChild(this._hoverChevron); + } + container.insertBefore(result.element, this.diffButton?.element ?? this._hoverChevron ?? null); + } + } else { + labelElement.appendChild(result.element); + if (!this.diffButton && this._hoverChevron) { + this._collapseButton.element.appendChild(this._hoverChevron); + } + } + previousTitleDetail?.remove(); + this.titleDetailContainer = result.element; + + const renderedTitle = result.element.textContent?.trim() || titleValue; + const thinkingLabel = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, renderedTitle); + this._collapseButton.element.ariaLabel = thinkingLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); + } + + private clearTitleDetail(): void { + this.titleDetailContainer?.remove(); + this.titleDetailContainer = undefined; + this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + + if (_element.isComplete) { + return true; + } + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && other.toolSpecificData?.kind === 'subagent' + && !other.subAgentInvocationId) { + return false; + } + + if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { + return true; + } + + if (other.kind !== 'thinking') { + return false; + } + + return other?.id !== this.id; + } + + override dispose(): void { + this.isActive = false; + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + this.pendingRemovalFlushDisposable?.dispose(); + this.pendingRemovalFlushDisposable = undefined; + this.pendingScrollDisposable?.dispose(); + super.dispose(); + } +} From 4529df6193e4fa0813a6a44f2ea398dd186d5a24 Mon Sep 17 00:00:00 2001 From: Emma <121360998+emxs1@users.noreply.github.com> Date: Thu, 10 Sep 2026 15:59:40 -0700 Subject: [PATCH 2/2] normalize line endings to LF --- .../chatThinkingContentPart.ts | 5532 ++++++++--------- 1 file changed, 2766 insertions(+), 2766 deletions(-) diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts index 6c38e94f28c870..1eaa26af25237d 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/chatThinkingContentPart.ts @@ -1,2766 +1,2766 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import { $, addDisposableListener, clearNode, DisposableResizeObserver, EventHelper, EventType, getWindow, hide, isHTMLElement, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js'; -import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; -import { Button } from '../../../../../../base/browser/ui/button/button.js'; -import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; -import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; -import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; -import { IChatExternalEdit, IChatMarkdownContent, IChatTerminalToolInvocationData, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; -import { IChatContentPart, IChatContentPartDiffData, IChatContentPartDiffResource, IChatContentPartRenderContext } from './chatContentParts.js'; -import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; -import { ChatConfiguration, ThinkingDisplayMode } from '../../../common/constants.js'; -import { ChatTreeItem } from '../../chat.js'; -import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; -import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; -import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; -import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; -import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../../base/common/htmlContent.js'; -import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; -import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; -import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; -import { basename, getComparisonKey } from '../../../../../../base/common/resources.js'; -import { URI } from '../../../../../../base/common/uri.js'; -import { ChatThinkingStyleContentPart, createThinkingIcon } from './chatThinkingStyleContentPart.js'; -export { createThinkingIcon }; -import { renderFileWidgets } from './chatInlineAnchorWidget.js'; -import { localize } from '../../../../../../nls.js'; -import { Codicon } from '../../../../../../base/common/codicons.js'; -import { ThemeIcon } from '../../../../../../base/common/themables.js'; -import { Lazy } from '../../../../../../base/common/lazy.js'; -import { Emitter, Event } from '../../../../../../base/common/event.js'; -import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; -import { autorun, IReader } from '../../../../../../base/common/observable.js'; -import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; -import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; -import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; -import './media/chatThinkingContent.css'; -import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; -import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; -import { getCompactCodicon } from '../../chatIcons.js'; -import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; -import { IEditorService } from '../../../../../services/editor/common/editorService.js'; -import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; -import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; -import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; -import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js'; -import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; - - -// Context key id mirrored from `vs/sessions/common/contextkeys` (`IsPhoneLayoutContext`). -// Inlined as a string because `vs/workbench` must not import from `vs/sessions`. -const SESSIONS_IS_PHONE_LAYOUT_KEY = 'sessionsIsPhoneLayout'; - -/** - * Read-only chats and phone layouts use collapsed preview regardless of the configured thinking style. - */ -export function getEffectiveThinkingDisplayMode(configurationService: IConfigurationService, contextKeyService: IContextKeyService, readOnly = false): ThinkingDisplayMode { - if (readOnly || contextKeyService.getContextKeyValue(SESSIONS_IS_PHONE_LAYOUT_KEY) === true) { - return ThinkingDisplayMode.CollapsedPreview; - } - return configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; -} - -function extractTextFromPart(content: IChatThinkingPart): string { - const raw = Array.isArray(content.value) ? content.value.join('') : (content.value || ''); - return raw.trim(); -} - -function isEditToolId(toolId: string): boolean { - const lowerToolId = toolId.toLowerCase(); - return lowerToolId.includes('edit') || - lowerToolId.includes('create') || - lowerToolId.includes('replace') || - lowerToolId.includes('patch'); -} - -/** - * Returns true for edit tools whose generic display name should be replaced - * with "Editing files" while streaming (e.g. replace, multi-replace, patch, insertEdit). - * Excludes create and notebook tools which already have good labels. - */ -function isGenericEditToolId(toolId: string): boolean { - const lowerToolId = toolId.toLowerCase(); - if (lowerToolId.includes('create') || lowerToolId.includes('notebook')) { - return false; - } - return lowerToolId.includes('replace') || - lowerToolId.includes('patch') || - lowerToolId.includes('insertedit') || - lowerToolId.includes('insert_edit') || - lowerToolId.includes('editfile'); -} - -function isProblemsToolId(toolId: string | undefined): boolean { - switch (toolId?.toLowerCase()) { - case 'problems': - case 'get_errors': - case 'copilot_geterrors': - return true; - default: - return false; - } -} - -function isNoProblemsFoundResult(toolId: string | undefined, resultText: string | undefined): boolean { - return isProblemsToolId(toolId) && resultText?.toLowerCase().includes('no problems found') === true; -} - -export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon, resultText?: string): ThemeIcon { - if (isNoProblemsFoundResult(toolId, resultText)) { - return Codicon.search; - } - - if (registeredIcon) { - return registeredIcon; - } - - const lowerToolId = toolId.toLowerCase(); - - if (lowerToolId.includes('comment')) { - return Codicon.comment; - } - - if ( - lowerToolId.includes('search') || - lowerToolId.includes('grep') || - lowerToolId.includes('find') || - lowerToolId.includes('list') || - lowerToolId.includes('semantic') || - lowerToolId.includes('changes') || - lowerToolId.includes('codebase') || - lowerToolId.includes('checked') - ) { - return Codicon.search; - } - - if ( - lowerToolId.includes('read') || - lowerToolId.includes('get_file') || - lowerToolId.includes('problems') - ) { - return Codicon.book; - } - - if (isEditToolId(toolId)) { - return Codicon.pencil; - } - - if ( - lowerToolId.includes('terminal') - ) { - return Codicon.terminal; - } - - // default to generic tool icon - return Codicon.tools; -} - -function setThinkingIcon(iconElement: HTMLElement, icon: ThemeIcon): void { - iconElement.className = 'chat-thinking-icon'; - iconElement.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); -} - -function extractTitleFromThinkingContent(content: string): string | undefined { - const headerMatch = content.match(/^\*\*([^*]+)\*\*/); - return headerMatch ? headerMatch[1] : undefined; -} - -/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ -function isThinkingHeaderLine(line: string): boolean { - return /^\s*\*\*.+\*\*\s*$/.test(line); -} - -/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ -function stripStandaloneBold(text: string): string { - const trimmed = text.trim(); - if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { - return trimmed.slice(2, -2); - } - return text; -} - -/** - * Splits a reasoning-summary value into one markdown string per display row. - * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set - * and the value starts with a header, that header is dropped because it is - * surfaced as the collapsible title. Returns `undefined` unless the value has at - * least two header lines, so ordinary reasoning prose keeps single-block rendering. - */ -export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { - const sections: { isHeader: boolean; lines: string[] }[] = []; - for (const line of text.split('\n')) { - if (isThinkingHeaderLine(line)) { - sections.push({ isHeader: true, lines: [line] }); - } else if (sections.length === 0) { - sections.push({ isHeader: false, lines: [line] }); - } else { - sections[sections.length - 1].lines.push(line); - } - } - - if (sections.filter(section => section.isHeader).length < 2) { - return undefined; - } - - const dropFirst = dropLeadingHeader && sections[0].isHeader; - const rows: string[] = []; - sections.forEach((section, index) => { - const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; - const markdown = lines.join('\n').trim(); - if (markdown) { - rows.push(markdown); - } - }); - - return rows.length ? rows : undefined; -} - -type ChatThinkingTitle = string | IMarkdownString; - -function getThinkingTitleValue(title: ChatThinkingTitle): string { - return typeof title === 'string' ? title : title.value; -} - -function thinkingTitleEqual(first: ChatThinkingTitle, second: ChatThinkingTitle): boolean { - if (typeof first === 'string' || typeof second === 'string') { - return first === second; - } - return markdownStringEqual(first, second); -} - -/** - * Metadata passed to {@link ChatThinkingContentPart.appendItem} to drive - * title / icon extraction. The `kind` discriminates which payload is - * available; the thinking part inspects it to compute a label like - * "Edited foo.ts" without rendering the actual content itself (the - * factory provides the DOM). - */ -export type ChatThinkingItemMetadata = - | IChatToolInvocation - | IChatToolInvocationSerialized - | IChatMarkdownContent - | IChatExternalEdit; - -interface ILazyToolItem { - kind: 'tool'; - lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; - toolInvocationId?: string; - toolInvocationOrMarkdown?: ChatThinkingItemMetadata; - originalParent?: HTMLElement; - isHook?: boolean; -} - -interface ILazyThinkingItem { - kind: 'thinking'; - textContainer: HTMLElement; - content: IChatThinkingPart; -} - -type ILazyItem = ILazyToolItem | ILazyThinkingItem; -const THINKING_SCROLL_MAX_HEIGHT = 200; - -const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache'; -const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days -const TITLE_CACHE_MAX_ENTRIES = 1000; - -const enum WorkingMessageCategory { - Thinking = 'thinking', - Terminal = 'terminal', - Tool = 'tool' -} - -export const defaultThinkingMessages = [ - localize('chat.thinking.thinking.1', 'Thinking'), - localize('chat.thinking.thinking.2', 'Reasoning'), - localize('chat.thinking.thinking.3', 'Considering'), - localize('chat.thinking.thinking.4', 'Analyzing'), - localize('chat.thinking.thinking.5', 'Evaluating'), - localize('chat.thinking.thinking.6', 'Working'), -]; - -const terminalMessages = [ - localize('chat.thinking.terminal.1', 'Executing'), - localize('chat.thinking.terminal.2', 'Running'), - localize('chat.thinking.terminal.3', 'Processing'), -]; - -const toolMessages = [ - localize('chat.thinking.tool.1', 'Processing'), - localize('chat.thinking.tool.2', 'Preparing'), - localize('chat.thinking.tool.3', 'Loading'), - localize('chat.thinking.tool.4', 'Analyzing'), - localize('chat.thinking.tool.5', 'Evaluating'), -]; - -/** Easter-egg loading messages, used ~1 in {@link FUN_WORKING_MESSAGE_RATE} picks. */ -const funWorkingMessages = [ - // Generic - localize('chat.working.fun.1', "Bribing the hamster"), - localize('chat.working.fun.2', "Reticulating splines"), - localize('chat.working.fun.3', "Untangling the spaghetti"), - localize('chat.working.fun.4', "Communing with the codebase"), - localize('chat.working.fun.5', "Letting it cook"), - localize('chat.working.fun.6', "Thanking all the fish"), - localize('chat.working.fun.7', "Stabilizing the wormhole"), - localize('chat.working.fun.8', "Baking the ideas"), - - // Code - localize('chat.working.fun.code.1', "Consulting the oracle"), - localize('chat.working.fun.code.2', "Shooting for the stars"), - localize('chat.working.fun.code.3', "Stirring the solution"), - - // Minecraft - localize('chat.working.fun.minecraft.1', "Mining diamonds"), - localize('chat.working.fun.minecraft.2', "Digging straight down"), - localize('chat.working.fun.minecraft.3', "Mining at night"), - - // Microsoft - localize('chat.working.fun.ms.1', "Summoning Clippy"), -]; - -const FUN_WORKING_MESSAGE_RATE = 50; - -type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; - -function getCustomThinkingPhrases(configurationService: IConfigurationService): { customPhrases: string[]; replaceDefaults: boolean } { - const config = configurationService.getValue(ChatConfiguration.ThinkingPhrases); - const customPhrases = Array.isArray(config?.phrases) - ? config.phrases - .filter((phrase): phrase is string => typeof phrase === 'string') - .map(phrase => phrase.trim()) - .filter(phrase => phrase.length > 0) - : []; - - return { - customPhrases, - replaceDefaults: config?.mode === 'replace' && customPhrases.length > 0, - }; -} - -/** Returns an easter-egg message ~1 in {@link FUN_WORKING_MESSAGE_RATE}, else `undefined`. */ -export function maybePickFunWorkingMessage(configurationService: IConfigurationService, random = Math.random): string | undefined { - if (getCustomThinkingPhrases(configurationService).replaceDefaults) { - return undefined; - } - - if (Math.floor(random() * FUN_WORKING_MESSAGE_RATE) === 0) { - return funWorkingMessages[Math.floor(random() * funWorkingMessages.length)]; - } - return undefined; -} - -/** - * Builds a phrase pool from defaults and user-configured custom phrases. - * In 'replace' mode, only custom phrases are used; in 'append' mode (default), - * custom phrases are added to the defaults. - */ -export function buildPhrasePool(defaults: string[], configurationService: IConfigurationService): string[] { - const { customPhrases, replaceDefaults } = getCustomThinkingPhrases(configurationService); - - if (customPhrases.length > 0) { - return replaceDefaults ? [...customPhrases] : [...defaults, ...customPhrases]; - } - return [...defaults]; -} - -export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implements IChatContentPart { - - private static _codeBlockRendererSync(_languageId: string, text: string, _raw?: string): HTMLElement { - const codeElement = $('code'); - codeElement.textContent = text; - return codeElement; - } - - public readonly codeblocks: undefined; - public readonly codeblocksPartId: undefined; - - private readonly _onDidChangeHeight = this._register(new Emitter()); - private readonly _asyncRenderCallback = () => this._onDidChangeHeight.fire(); - - private id: string | undefined; - private content: IChatThinkingPart; - private currentThinkingValue: string; - private currentTitle: string; - private defaultTitle = localize('chat.thinking.header', 'Thinking'); - private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); - private textContainer!: HTMLElement; - private readonly _markdownResult = this._register(new MutableDisposable()); - private summaryRowItems: HTMLElement[] = []; - private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; - private summaryRowTexts: string[] = []; - private droppedSummaryHeader: string | undefined; - private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; - private wrapper!: HTMLElement; - private fixedScrollingMode: boolean = false; - private readonly thinkingDisplayMode: ThinkingDisplayMode; - private autoScrollEnabled: boolean = true; - private scrollableElement: DomScrollableElement | undefined; - private lastExtractedTitle: string | undefined; - private extractedTitles: string[] = []; - private toolInvocationCount: number = 0; - private appendedItemCount: number = 0; - private isActive: boolean = true; - private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; - private allThinkingParts: IChatThinkingPart[] = []; - private hookCount: number = 0; - private singleItemInfo: { element: HTMLElement; thinkingWrapper: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null; restoreToOriginalParent: boolean; toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized } | undefined; - private lazyItems: ILazyItem[] = []; - private hasExpandedOnce: boolean = false; - private workingSpinnerElement: HTMLElement | undefined; - private workingSpinnerLabel: HTMLElement | undefined; - private availableMessagesByCategory = new Map(); - private readonly toolWrappersByCallId = new Map(); - private readonly toolIconsByCallId = new Map(); - private readonly toolLabelsByCallId = new Map(); - private readonly toolDisposables = this._register(new DisposableMap()); - private readonly ownedToolParts = new Map(); - private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; - private pendingRemovalFlushDisposable: IDisposable | undefined; - private pendingScrollDisposable: IDisposable | undefined; - private wrapperResizeObserverDisposable: IDisposable | undefined; - private childResizeObserver: DisposableResizeObserver | undefined; - private isUpdatingDimensions: boolean = false; - private lastKnownContentHeight: number = 0; - private lastKnownScrollTop: number = 0; - private titleDetailContainer: HTMLElement | undefined; - private lastRenderedTitle: ChatThinkingTitle | undefined; - private collapsedTitleBeforeExpansion: ChatThinkingTitle | undefined; - private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; - private readonly _pendingExternalResources = new Map(); - private readonly _titleDetailRendered = this._register(new MutableDisposable()); - private readonly _pendingAppendRefresh = this._register(new MutableDisposable()); - private readonly diffDataByPartId = new Map(); - private _aggregatedDiff: IEditSessionDiffStats = { added: 0, removed: 0 }; - private readonly diffButtonStore = this._register(new DisposableStore()); - private diffButton: Button | undefined; - private containsReasoning: boolean; - private containsGroupedItems: boolean = false; - private reasoningDurationMs: number | undefined; - - get aggregatedDiff(): IEditSessionDiffStats { return this._aggregatedDiff; } - - private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { - const fun = maybePickFunWorkingMessage(this.configurationService); - if (fun) { - return fun; - } - - let pool = this.availableMessagesByCategory.get(category); - if (!pool || pool.length === 0) { - let defaults: string[]; - switch (category) { - case WorkingMessageCategory.Thinking: - defaults = defaultThinkingMessages; - break; - case WorkingMessageCategory.Terminal: - defaults = terminalMessages; - break; - case WorkingMessageCategory.Tool: - default: - defaults = toolMessages; - break; - } - - pool = buildPhrasePool(defaults, this.configurationService); - - this.availableMessagesByCategory.set(category, pool); - } - const index = Math.floor(Math.random() * pool.length); - return pool.splice(index, 1)[0]; - } - - constructor( - content: IChatThinkingPart, - context: IChatContentPartRenderContext, - private readonly chatContentMarkdownRenderer: IMarkdownRenderer, - private streamingCompleted: boolean, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, - @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, - @IHoverService hoverService: IHoverService, - @ITelemetryService telemetryService: ITelemetryService, - @IStorageService private readonly storageService: IStorageService, - @IContextKeyService contextKeyService: IContextKeyService, - @IEditorService private readonly editorService: IEditorService, - ) { - const initialText = extractTextFromPart(content); - const containsReasoning = initialText.trim().length > 0; - const extractedTitle = extractTitleFromThinkingContent(initialText) - ?? localize('chat.thinking.header.initial', 'Thinking'); - - super(extractedTitle, context, undefined, hoverService, configurationService, telemetryService); - - this.containsReasoning = containsReasoning; - this.reasoningDurationMs = content.reasoningDurationMs; - this.id = content.id; - this.content = content; - this.allThinkingParts.push(content); - const configuredMode = getEffectiveThinkingDisplayMode(this.configurationService, contextKeyService, context.readOnly); - this.thinkingDisplayMode = configuredMode; - - this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; - - this.currentTitle = extractedTitle; - if (extractedTitle !== this.defaultTitle) { - this.lastExtractedTitle = extractedTitle; - this.extractedTitles.push(extractedTitle); - } - this.currentThinkingValue = initialText; - this.trackDroppedSummaryHeader(initialText); - - if (initialText.trim()) { - this.appendedItemCount++; - } - - // Alert screen reader users that thinking has started - if (this.configurationService.getValue(AccessibilityWorkbenchSettingId.VerboseChatProgressUpdates)) { - alert(localize('chat.thinking.started', 'Thinking')); - } - - if (configuredMode === ThinkingDisplayMode.Collapsed) { - this.setExpanded(false); - } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { - // Start expanded if still in progress. - // streamingCompleted is true when look-ahead finds subsequent non-pinnable - // parts, meaning this thinking part won't receive more content. - this.setExpanded(!this.streamingCompleted && !this.element.isComplete); - } else { - this.setExpanded(false); - } - - const node = this.domNode; - if (this._hoverChevron) { - this._register(addDisposableListener(this._hoverChevron, EventType.CLICK, event => { - EventHelper.stop(event, true); - this.toggleExpanded(); - })); - } - - this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); - this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); - node.appendChild(this._externalResourceWidget.domNode); - - if (!this.streamingCompleted && !this.element.isComplete) { - if (!this.fixedScrollingMode) { - node.classList.add('chat-thinking-active'); - } - } - - if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { - this.setShimmerTitle(extractedTitle); - } - - if (this.fixedScrollingMode) { - node.classList.add('chat-thinking-fixed-mode'); - this.currentTitle = this.defaultTitle; - } - - this._register(toDisposable(() => { - for (const d of this.ownedToolParts.values()) { - d.dispose(); - } - this.ownedToolParts.clear(); - })); - - this._register(toDisposable(() => { - for (const result of this.summaryRowResults) { - result?.dispose(); - } - for (const result of this.retiredSummaryRowResults) { - result.dispose(); - } - })); - - this._register(autorun(r => { - const isExpanded = this._isExpanded.read(r); - // Materialize lazy items when first expanded - if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { - this.hasExpandedOnce = true; - // Flush pending removals so that completed hidden tools are removed from lazyItems before materialization - this.processPendingRemovals(); - for (const item of this.lazyItems) { - this.materializeLazyItem(item); - } - } - - // If expanded but content matches title and there's nothing else to show, revert immediately. - // Skip this check while still streaming — more content will arrive. - if (isExpanded && !this.shouldAllowExpansion() && (this.streamingCompleted || this.element.isComplete)) { - this.setExpanded(false); - return; - } - - this._externalResourceWidget.setCollapsed(!isExpanded); - - // Fire when expanded/collapsed - this._onDidChangeHeight.fire(); - })); - - const label = this.lastExtractedTitle ?? ''; - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(label); - } - - if (this._collapseButton) { - this._register(this._collapseButton.onDidClick(() => { - if (this.fixedScrollingMode) { - if (this.streamingCompleted) { - this.domNode.classList.add('chat-thinking-fixed-mode-animated'); - } - return; - } - - if (this.streamingCompleted) { - return; - } - - const expanded = this.isExpanded(); - if (expanded) { - // Just expanded: show plain 'Working' with no detail - this.collapsedTitleBeforeExpansion = this.lastRenderedTitle ?? this.lastExtractedTitle; - this.setTitle(this.defaultTitle, true); - this.currentTitle = this.defaultTitle; - } else { - // Restore the title that was visible before expansion. Tool state - // updates can become less descriptive while the section is open. - const collapsedTitle = this.collapsedTitleBeforeExpansion ?? this.lastRenderedTitle ?? this.lastExtractedTitle; - this.collapsedTitleBeforeExpansion = undefined; - if (collapsedTitle) { - this.setTitle(collapsedTitle); - } else { - this.setTitle(this.defaultTitle, true); - this.currentTitle = this.defaultTitle; - } - } - })); - } - } - - protected override shouldInitEarly(): boolean { - return this.fixedScrollingMode && !this.streamingCompleted; - } - - protected override shouldAnimateContent(): boolean { - return !this.fixedScrollingMode; - } - - protected override shouldPrepareContentAnimation(): boolean { - return !this.fixedScrollingMode; - } - - protected override contentDidInitialize(): void { - if (this.fixedScrollingMode && this.streamingCompleted && this.scrollableElement) { - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = '0px'; - scrollableDomNode.getBoundingClientRect(); - } - } - - protected override get collapsibleKind(): string { - return 'thinking'; - } - - protected override expansionDidChange(expanded: boolean): void { - if (this.fixedScrollingMode && this.streamingCompleted) { - if (expanded) { - this.syncDimensionsAndScheduleScroll(); - } else { - this.updateCompletedScrollAnimationState(false); - } - } - } - - // @TODO: @justschen Convert to template for each setting? - protected override getThinkingIcon(_active: boolean, expanded: boolean): ThemeIcon { - if (this.streamingCompleted || this.element.isComplete) { - return Codicon.checkCompact; - } - return !this.fixedScrollingMode && expanded ? Codicon.chevronDownCompact : Codicon.circleFilledCompact; - } - - protected override initContent(): HTMLElement { - this.wrapper = this.createThinkingBody(); - if (!this.streamingCompleted) { - this.wrapper.classList.add('chat-thinking-streaming'); - } - - // Only create textContainer here if there's no pending lazy thinking item. - // If there's a lazy thinking item, it will be rendered via materializeLazyItem - // with the latest streaming content. - const hasLazyThinkingItems = this.lazyItems.some(item => item.kind === 'thinking'); - if (this.currentThinkingValue && !hasLazyThinkingItems) { - this.textContainer = $('.chat-thinking-item.markdown-content'); - this.wrapper.appendChild(this.textContainer); - this.renderMarkdown(this.currentThinkingValue); - } - - if (!this.streamingCompleted && !this.element.isComplete) { - const spinner = this.createThinkingSpinnerRow(this.getRandomWorkingMessage(WorkingMessageCategory.Thinking)); - this.workingSpinnerElement = spinner.row; - this.workingSpinnerLabel = spinner.label; - this.wrapper.appendChild(spinner.row); - this.updateWorkingSpinnerVisibility(); - } - - // wrap content in scrollable element for fixed scrolling mode - if (this.fixedScrollingMode) { - this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { - vertical: ScrollbarVisibility.Auto, - horizontal: ScrollbarVisibility.Hidden, - handleMouseWheel: true, - alwaysConsumeMouseWheel: false - })); - this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop))); - - let pendingMutationRefresh: IDisposable | undefined; - const mutationObserver = new MutationObserver(() => { - if (pendingMutationRefresh) { - return; - } - pendingMutationRefresh = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { - pendingMutationRefresh = undefined; - if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - }); - mutationObserver.observe(this.wrapper, { childList: true, subtree: true }); - this._register({ - dispose: () => { - mutationObserver.disconnect(); - pendingMutationRefresh?.dispose(); - } - }); - - // Observe child elements for resizes (e.g. terminal output growing) - // so we can update scroll dimensions when the wrapper box is pinned at max-height. - this.childResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.child', () => { - if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { - return; - } - - this.syncDimensionsAndScheduleScroll(); - })); - if (this.textContainer) { - this._register(this.childResizeObserver.observe(this.textContainer)); - } - if (this.workingSpinnerElement) { - this._register(this.childResizeObserver.observe(this.workingSpinnerElement)); - } - - // Cache wrapper scrollHeight post-layout via ResizeObserver to avoid forced reflows. - const wrapperResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.wrapper', (entries) => { - if (entries[0]) { - this.lastKnownContentHeight = this.wrapper.scrollHeight; - if (this.streamingCompleted && this.isExpanded()) { - this.updateScrollDimensionsForCompletion(); - } else if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { - this.updateScrollDimensionsFromCache(); - } - } - })); - this.wrapperResizeObserverDisposable = this._register(wrapperResizeObserver.observe(this.wrapper)); - - // Once content exceeds max-height, the wrapper box size stops changing - // so ResizeObserver won't fire. Fall back to scrollHeight reads here. - this._register(this._onDidChangeHeight.event(() => { - if (!this.streamingCompleted && this.wrapperResizeObserverDisposable) { - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - return; - } - this.syncDimensionsAndScheduleScroll(); - })); - - this.syncDimensionsAndScheduleScroll(); - - this.updateDropdownClickability(); - return this.scrollableElement.getDomNode(); - } - - this.updateDropdownClickability(); - return this.wrapper; - } - - private handleScroll(scrollTop: number): void { - if (!this.scrollableElement || this.isUpdatingDimensions) { - return; - } - - this.lastKnownScrollTop = scrollTop; - const contentHeight = this.lastKnownContentHeight; - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - const maxScrollTop = contentHeight - viewportHeight; - this.autoScrollEnabled = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; - - this.updateFadeClasses(scrollTop, contentHeight, viewportHeight); - } - - private updateFadeClasses(scrollTop?: number, contentHeight?: number, viewportHeight?: number): void { - if (!this.fixedScrollingMode || this.streamingCompleted) { - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - return; - } - - const currentScrollTop = scrollTop ?? this.lastKnownScrollTop; - const currentContentHeight = contentHeight ?? this.lastKnownContentHeight; - const currentViewportHeight = viewportHeight ?? Math.min(currentContentHeight, THINKING_SCROLL_MAX_HEIGHT); - const maxScrollTop = currentContentHeight - currentViewportHeight; - - this.domNode.classList.toggle('chat-thinking-fade-top', currentScrollTop > 5); - this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5); - } - - // Fallback for non-ResizeObserver updates (onDidChangeHeight, initial setup). - private syncDimensionsAndScheduleScroll(): void { - if (this.pendingScrollDisposable) { - return; - } - this.pendingScrollDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { - this.pendingScrollDisposable = undefined; - if (this._store.isDisposed) { - return; - } - if (this.streamingCompleted) { - this.updateScrollDimensionsForCompletion(); - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - } - - /** - * Re-read scrollHeight from the DOM and update cached height if changed. - */ - private refreshContentHeight(): void { - if (!this.wrapper || !this.scrollableElement) { - return; - } - const newHeight = this.wrapper.scrollHeight; - if (newHeight && newHeight !== this.lastKnownContentHeight) { - this.lastKnownContentHeight = newHeight; - } - } - - private updateScrollDimensionsFromCache(): void { - if (!this.scrollableElement || this._store.isDisposed) { - return; - } - - const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed'); - if (!isCollapsed) { - return; - } - - const contentHeight = this.lastKnownContentHeight; - if (!contentHeight) { - return; - } - - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - - this.isUpdatingDimensions = true; - try { - const viewportWidth = this.scrollableElement.getDomNode().clientWidth; - this.scrollableElement.setScrollDimensions({ - width: viewportWidth, - scrollWidth: viewportWidth, - height: viewportHeight, - scrollHeight: contentHeight - }); - - if (this.autoScrollEnabled) { - this.scrollToBottom(contentHeight); - } - } finally { - this.isUpdatingDimensions = false; - } - - this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight); - this.updateDropdownClickability(contentHeight); - } - - private scrollToBottom(contentHeight: number): void { - if (!this.scrollableElement) { - return; - } - - const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); - - if (contentHeight > viewportHeight) { - const newScrollTop = contentHeight - viewportHeight; - this.lastKnownScrollTop = newScrollTop; - // Prevent reveal-on-scroll behavior from interfering with explicit bottom pinning. - this.scrollableElement.setRevealOnScroll(false); - this.scrollableElement.setScrollPosition({ scrollTop: newScrollTop }); - this.scrollableElement.setRevealOnScroll(true); - } - } - - /** - * updates scroll dimensions when streaming is complete. - */ - private updateScrollDimensionsForCompletion(): void { - if (!this.scrollableElement || !this.fixedScrollingMode) { - return; - } - - const contentHeight = this.wrapper.scrollHeight; - this.lastKnownContentHeight = contentHeight; - - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = `${contentHeight}px`; - const viewportWidth = scrollableDomNode.clientWidth; - this.scrollableElement.setScrollDimensions({ - width: viewportWidth, - scrollWidth: viewportWidth, - height: contentHeight, - scrollHeight: contentHeight - }); - this.lastKnownScrollTop = 0; - this.scrollableElement.setRevealOnScroll(false); - this.scrollableElement.setScrollPosition({ scrollTop: 0 }); - this.scrollableElement.setRevealOnScroll(true); - this.updateCompletedScrollAnimationState(this.isExpanded()); - } - - private updateCompletedScrollAnimationState(expanded: boolean): void { - if (!this.scrollableElement) { - return; - } - const scrollableDomNode = this.scrollableElement.getDomNode(); - scrollableDomNode.style.maxHeight = expanded ? `${this.lastKnownContentHeight}px` : '0px'; - scrollableDomNode.inert = !expanded; - } - - private renderMarkdown(content: string, reuseExisting?: boolean): void { - // Guard against rendering after disposal to avoid leaking disposables - if (this._store.isDisposed) { - return; - } - - // A later thinking part reassigns textContainer; retire stale row tracking - // so the predecessor's rendered rows stay frozen while this part renders. - if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { - this.retireSummaryRows(); - } - - const cleanedContent = content.trim(); - if (!cleanedContent) { - this._markdownResult.clear(); - this.clearSummaryRows(); - if (this.textContainer) { - clearNode(this.textContainer); - } - return; - } - - // Multi-header reasoning summaries render each header section as its own - // row so the dropdown reads as a list. Sibling rows need an attached container so their - // insertion isn't a no-op, so a detached (lazy) container falls through to - // single-block rendering until it is materialized. A block drops its leading - // header only when that header is the tracked title owner, so a grouped block - // never drops a header that isn't surfaced as the title. - const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; - const summaryRows = splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); - if (summaryRows && this.textContainer?.parentNode) { - this.renderSummaryRows(summaryRows); - return; - } - this.clearSummaryRows(); - - // If the entire content is bolded, strip the bold markers for rendering - const contentToRender = stripStandaloneBold(cleanedContent); - - const target = reuseExisting ? this._markdownResult.value?.element : undefined; - - const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(contentToRender), { - fillInIncompleteTokens: true, - asyncRenderCallback: this._asyncRenderCallback, - codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, - }, target); - this._markdownResult.value = rendered; - if (!target) { - if (this.textContainer) { - clearNode(this.textContainer); - this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); - this.textContainer.appendChild(rendered.element); - } - } - } - - /** Renders one summary row, reusing the row's element while its text only grows. */ - private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { - const previous = this.summaryRowResults[index]; - const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); - // A standalone header renders as plain text, not bold. - const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { - fillInIncompleteTokens: true, - asyncRenderCallback: this._asyncRenderCallback, - codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, - }, reuse ? previous?.element : undefined); - if (!reuse) { - clearNode(container); - container.appendChild(createThinkingIcon(Codicon.circleFilled)); - container.appendChild(rendered.element); - } - previous?.dispose(); - this.summaryRowResults[index] = rendered; - this.summaryRowTexts[index] = markdown; - } - - private renderSummaryRows(rows: string[]): void { - // Rows own the DOM in this mode; release the single-block renderer. - this._markdownResult.clear(); - - for (let i = 0; i < rows.length; i++) { - let container = this.summaryRowItems[i]; - if (!container) { - container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); - this.summaryRowItems[i] = container; - this.summaryRowTexts[i] = ''; - if (i === 0) { - clearNode(container); - } else { - this.summaryRowItems[i - 1].after(container); - } - } - if (this.summaryRowTexts[i] !== rows[i]) { - this.renderSummaryRow(container, i, rows[i]); - } - } - - // Streaming only appends, but guard against a shrinking row set on re-render. - for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { - this.summaryRowResults[i]?.dispose(); - if (this.summaryRowItems[i] !== this.textContainer) { - this.summaryRowItems[i].remove(); - } - } - this.summaryRowItems.length = rows.length; - this.summaryRowResults.length = rows.length; - this.summaryRowTexts.length = rows.length; - } - - /** Removes the extra summary rows and resets tracking, keeping the text container. */ - private clearSummaryRows(): void { - if (!this.summaryRowItems.length) { - return; - } - for (let i = 0; i < this.summaryRowItems.length; i++) { - this.summaryRowResults[i]?.dispose(); - if (i !== 0) { - this.summaryRowItems[i].remove(); - } - } - this.summaryRowItems = []; - this.summaryRowResults = []; - this.summaryRowTexts = []; - } - - /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ - private retireSummaryRows(): void { - for (const result of this.summaryRowResults) { - if (result) { - this.retiredSummaryRowResults.push(result); - } - } - this.summaryRowItems = []; - this.summaryRowResults = []; - this.summaryRowTexts = []; - } - - /** - * Records the leading header the primary summary block drops, derived from content - * so it is available at finalize even when the rows never lazily rendered (the - * collapsed-through-completion flow). First-writer wins: the first grouped block - * that is a multi-header summary owns the title, and only that header is dropped. - */ - private trackDroppedSummaryHeader(value: string): void { - if (this.droppedSummaryHeader) { - return; - } - const trimmed = value.trim(); - if (splitReasoningSummaryRows(trimmed, true)) { - this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); - if (this.fixedScrollingMode && this.droppedSummaryHeader && this.currentTitle !== this.droppedSummaryHeader) { - this.setTitle(this.droppedSummaryHeader); - } - } - } - - private setFinalizedTitle(title: string): void { - if (!this._collapseButton) { - return; - } - - const displayTitle = this.getFinalizedDisplayTitle(title); - this.clearTitleDetail(); - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; - this.forgetShimmerTitle(); - - const firstSpaceIndex = displayTitle.indexOf(' '); - if (firstSpaceIndex === -1) { - // Single word title, no need to split - labelElement.textContent = displayTitle; - } else { - const verb = displayTitle.substring(0, firstSpaceIndex); - const rest = displayTitle.substring(firstSpaceIndex); - - const verbSpan = $('span'); - verbSpan.textContent = verb; - labelElement.appendChild(verbSpan); - - const restSpan = $('span.chat-thinking-title-detail-text'); - restSpan.textContent = rest; - labelElement.appendChild(restSpan); - } - - // Show aggregated diff stats from edit pills (only when there are actual changes) - if (this.diffDataByPartId.size > 0) { - const { added, removed } = this._aggregatedDiff; - if (added > 0 || removed > 0) { - this.renderDiffButton(added, removed); - - const insertionsFragment = added === 1 ? localize('chat.thinking.insertions.one', "1 insertion") : localize('chat.thinking.insertions', "{0} insertions", added); - const deletionsFragment = removed === 1 ? localize('chat.thinking.deletions.one', "1 deletion") : localize('chat.thinking.deletions', "{0} deletions", removed); - this.setAriaLabel(localize('chat.thinking.titleWithDiff', "{0}, {1}, {2}", displayTitle, insertionsFragment, deletionsFragment)); - } else { - this.clearDiffButton(); - this.setAriaLabel(displayTitle); - } - } else { - this.clearDiffButton(); - this.setAriaLabel(displayTitle); - } - } - - private renderDiffButton(added: number, removed: number): void { - const resources = this.getAggregatedDiffResources(); - if (resources.length === 0) { - this.clearDiffButton(); - return; - } - - if (!this.diffButton) { - const collapseButton = this._collapseButton; - const container = collapseButton?.element.parentElement; - if (!container) { - return; - } - - collapseButton.element.classList.add('chat-thinking-title-with-diff'); - const button = this.diffButtonStore.add(new Button(container, {})); - button.element.classList.add('chat-thinking-title-diff'); - this.diffButtonStore.add(button.onDidClick(event => { - EventHelper.stop(event, true); - this.openDiffs(); - })); - this.diffButtonStore.add(this.hoverService.setupDelayedHover(button.element, { - content: localize('chat.thinking.viewChanges', "View File Changes"), - style: HoverStyle.Pointer, - })); - this.diffButton = button; - - if (this._hoverChevron) { - container.appendChild(this._hoverChevron); - } - } - - this.diffButton.element.replaceChildren( - $('span.label-added', {}, `+${added}`), - $('span.label-removed', {}, `-${removed}`), - ); - this.diffButton.setAriaLabel(localize( - 'chat.thinking.viewChangesAccessible', - 'View file changes, {0} lines added, {1} lines deleted', - added, - removed, - )); - } - - private clearDiffButton(): void { - this.diffButtonStore.clear(); - this.diffButton = undefined; - const collapseButton = this._collapseButton; - collapseButton?.element.classList.remove('chat-thinking-title-with-diff'); - const container = collapseButton?.element.parentElement; - if (collapseButton && container && this._hoverChevron) { - if (this.titleDetailContainer?.parentElement === container) { - container.appendChild(this._hoverChevron); - } else { - collapseButton.element.appendChild(this._hoverChevron); - } - } - } - - private getAggregatedDiffResources(): IChatContentPartDiffResource[] { - const result = new Map(); - - for (const data of this.diffDataByPartId.values()) { - for (const resource of data.resources) { - const key = getComparisonKey(resource.resource); - const existing = result.get(key); - if (existing) { - existing.resource = resource.resource; - existing.modifiedURI = resource.modifiedURI; - } else { - result.set(key, { ...resource }); - } - } - } - - return [...result.values()].filter(resource => resource.originalURI !== undefined || resource.modifiedURI !== undefined); - } - - private openDiffs(): void { - const resources = this.getAggregatedDiffResources(); - if (resources.length === 0) { - return; - } - - const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); - this.editorService.openEditor({ - multiDiffSource: source, - label: localize('chat.thinking.changes.title', "Section File Changes"), - resources: resources.map(resource => ({ - original: { resource: resource.originalURI }, - modified: { resource: resource.modifiedURI }, - goToFileResource: resource.resource, - })), - }); - } - - private getFinalizedDisplayTitle(title: string): string { - if (this.thinkingDisplayMode !== ThinkingDisplayMode.Collapsed || !this.containsReasoning || this.containsGroupedItems || !this.reasoningDurationMs) { - return title; - } - - const seconds = Math.ceil(this.reasoningDurationMs / 1000); - const duration = localize('chat.thinking.duration.seconds', "{0}s", seconds); - return localize('chat.thinking.titleWithDuration', "{0} - {1}", title, duration); - } - - public hasReasoningContent(): boolean { - return this.containsReasoning; - } - - public hasGroupedItems(): boolean { - return this.containsGroupedItems; - } - - private recordReasoningContent(content: string): void { - if (!content.trim()) { - return; - } - this.containsReasoning = true; - } - - private setDropdownClickable(clickable: boolean): void { - if (this._collapseButton) { - this._collapseButton.element.style.pointerEvents = clickable ? 'auto' : 'none'; - } - - if (!clickable && this.streamingCompleted) { - this.setFinalizedTitle(this.lastExtractedTitle ?? this.currentTitle); - } - } - - private shouldAllowExpansion(): boolean { - // Multiple tool invocations or lazy items mean there's content to show - if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { - return true; - } - - // Count meaningful children in the wrapper (exclude the working spinner) - if (this.wrapper) { - const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; - if (meaningfulChildren > 1) { - return true; - } - } - - const contentWithoutTitle = this.currentThinkingValue.trim(); - const titleToCompare = this.lastExtractedTitle ?? this.currentTitle; - - const stripMarkdown = (text: string) => { - return text - .replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1').trim(); - }; - - const strippedContent = stripMarkdown(contentWithoutTitle); - // If content is empty or matches the title exactly, nothing to expand - return !(!strippedContent || strippedContent === titleToCompare); - } - - private updateDropdownClickability(knownContentHeight?: number): void { - let allowExpansion = this.shouldAllowExpansion(); - - // don't allow feedback on fixed scrolling before reaching max height. - if (allowExpansion && this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this.wrapper) { - // Use only the cached height — never read scrollHeight here to avoid forced reflows. - // If the cache is empty, conservatively disallow expansion; the ResizeObserver - // will populate lastKnownContentHeight and trigger another call once layout settles. - const contentHeight = knownContentHeight ?? this.lastKnownContentHeight; - if (!contentHeight || contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { - allowExpansion = false; - } - } - - if (!allowExpansion && this.isExpanded() && (this.streamingCompleted || this.element.isComplete)) { - this.setExpanded(false); - } - this.setDropdownClickable(allowExpansion); - } - - private appendToWrapper(element: HTMLElement): void { - if (!this.wrapper) { - return; - } - if (this.workingSpinnerElement && this.workingSpinnerElement.parentNode === this.wrapper) { - this.wrapper.insertBefore(element, this.workingSpinnerElement); - } else { - this.wrapper.appendChild(element); - } - } - - private updateWorkingSpinnerVisibility(reader?: IReader): void { - if (!this.wrapper || !this.workingSpinnerElement) { - return; - } - - const hasRunningTerminalTool = this.toolInvocations.some(toolInvocation => { - const terminalData = toolInvocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; - if (terminalData?.kind !== 'terminal' || terminalData.terminalCommandState?.exitCode !== undefined) { - return false; - } - - return !IChatToolInvocation.isComplete(toolInvocation, reader); - }); - - const isAttached = this.workingSpinnerElement.parentNode === this.wrapper; - if (hasRunningTerminalTool && isAttached) { - this.workingSpinnerElement.remove(); - this._onDidChangeHeight.fire(); - } else if (!hasRunningTerminalTool && !isAttached && !this.streamingCompleted && !this.element.isComplete) { - this.wrapper.appendChild(this.workingSpinnerElement); - this._onDidChangeHeight.fire(); - } - } - - public resetId(): void { - this.id = undefined; - } - - public collapseContent(): void { - this.setExpanded(false); - } - - public updateThinking(content: IChatThinkingPart): void { - // If disposed, ignore late updates coming from renderer diffing - if (this._store.isDisposed) { - return; - } - this.content = content; - this.reasoningDurationMs = content.reasoningDurationMs; - - // Update any pending lazy thinking item with matching ID so that - // when materialized, it will have the latest streaming content - for (const lazyItem of this.lazyItems) { - if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) { - lazyItem.content = content; - break; - } - } - - const raw = extractTextFromPart(content); - this.recordReasoningContent(raw); - const next = raw; - if (next === this.currentThinkingValue) { - return; - } - const previousValue = this.currentThinkingValue; - const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); - this.currentThinkingValue = next; - this.trackDroppedSummaryHeader(next); - this.renderMarkdown(next, reuseExisting); - - if (this.fixedScrollingMode && this.scrollableElement) { - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - } - - const extractedTitle = extractTitleFromThinkingContent(raw); - if (extractedTitle && extractedTitle !== this.currentTitle) { - if (!this.extractedTitles.includes(extractedTitle)) { - this.extractedTitles.push(extractedTitle); - } - this.lastExtractedTitle = extractedTitle; - } - - if (!extractedTitle || extractedTitle === this.currentTitle) { - return; - } - - const label = this.lastExtractedTitle ?? ''; - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(label); - } - - this.updateDropdownClickability(); - } - - public getIsActive(): boolean { - return this.isActive; - } - - /** - * Returns true when this thinking part has no meaningful content to display: - * no tool invocations, no lazy items, no hooks, and no thinking text. - * This happens when a tool is removed from thinking (e.g. due to confirmation) - * and the thinking part was only created to hold that tool. - */ - public isEffectivelyEmpty(): boolean { - this.processPendingRemovals(); - if (this.toolInvocationCount > 0 || this.lazyItems.length > 0 || this.hookCount > 0) { - return false; - } - if (this.currentThinkingValue.trim().length > 0) { - return false; - } - return true; - } - - public markAsInactive(): void { - this.isActive = false; - this.domNode.classList.remove('chat-thinking-active'); - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - this.processPendingRemovals(); - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - - // Clear the attached-to-thinking flag on all tool invocations - for (const toolInvocation of this.toolInvocations) { - toolInvocation.isAttachedToThinking = false; - } - } - - public finalizeTitleIfDefault(): void { - this.processPendingRemovals(); - - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded - if (this.wrapper) { - this.wrapper.classList.remove('chat-thinking-streaming'); - } - this.domNode.classList.remove('chat-thinking-active'); - this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); - this.streamingCompleted = true; - this.setContentAnimationEnabled(!this.fixedScrollingMode); - - // Now that streaming is complete, render any aggregated images that were - // deferred while scrolling was pinned in fixed scrolling mode. - this.flushPendingExternalResources(); - - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - - if (this._collapseButton) { - this._collapseButton.icon = Codicon.checkCompact; - } - - // Update scroll dimensions now that streaming is complete - // This removes unnecessary scrollbar when content fits - this.updateScrollDimensionsForCompletion(); - - this.updateDropdownClickability(); - - // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. - if (this.droppedSummaryHeader) { - this.currentTitle = this.droppedSummaryHeader; - this.content.generatedTitle = this.droppedSummaryHeader; - this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); - this.setFinalizedTitle(this.droppedSummaryHeader); - return; - } - - if (this.content.generatedTitle) { - this.currentTitle = this.content.generatedTitle; - this.setGeneratedTitleOnAllParts(this.content.generatedTitle); - this.setFinalizedTitle(this.content.generatedTitle); - return; - } - - // Reuse any existing generated title from tool invocations or thinking parts. - const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle - ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; - if (existingTitle) { - this.currentTitle = existingTitle; - this.content.generatedTitle = existingTitle; - this.setGeneratedTitleOnAllParts(existingTitle); - this.setFinalizedTitle(existingTitle); - return; - } - - // Only check the persisted cache when re-rendering (tool invocations are - // serialized), not during live streaming. Reasoning-only blocks (no tools) - // are keyed off the stable thinking part id so their generated headers are - // also restored on reload (non-local sessions only). - const allToolsSerialized = this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized'); - if (allToolsSerialized && !LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { - const cacheId = this.getTitleCacheId(); - if (cacheId) { - const cachedTitle = this.getCachedTitle(cacheId); - if (cachedTitle) { - this.currentTitle = cachedTitle; - this.content.generatedTitle = cachedTitle; - this.setGeneratedTitleOnAllParts(cachedTitle); - this.setFinalizedTitle(cachedTitle); - return; - } - } - } - - // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position - if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') { - // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now - if (!this.singleItemInfo) { - const lazyItem = this.lazyItems.find(item => item.kind === 'tool' && item.originalParent); - if (lazyItem && lazyItem.kind === 'tool') { - const toolInvocation = lazyItem.toolInvocationOrMarkdown && (lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? lazyItem.toolInvocationOrMarkdown : undefined; - const result = lazyItem.lazy.value; - this.appendItemToDOM(result.domNode, lazyItem.toolInvocationId, lazyItem.toolInvocationOrMarkdown, lazyItem.originalParent); - if (result.disposable) { - const toolCallId = toolInvocation?.toolCallId; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } - } - if (this.singleItemInfo && this.restoreSingleItemToOriginalPosition()) { - return; - } - } - - // if exactly one actual extracted title and no tool invocations, use that as the final title. - if (this.extractedTitles.length === 1 && this.toolInvocationCount === 0) { - const title = this.extractedTitles[0]; - this.currentTitle = title; - this.content.generatedTitle = title; - this.setGeneratedTitleOnAllParts(title); - this.setFinalizedTitle(title); - return; - } - - const generateTitles = this.configurationService.getValue(ChatConfiguration.ThinkingGenerateTitles) ?? true; - if (!generateTitles) { - this.setFallbackTitle(); - return; - } - - this.generateTitleViaLLM(); - } - - private setGeneratedTitleOnAllParts(title: string): void { - for (const toolInvocation of this.toolInvocations) { - toolInvocation.generatedTitle = title; - } - for (const thinkingPart of this.allThinkingParts) { - thinkingPart.generatedTitle = title; - } - } - - private loadTitleCache(): Record { - return this.storageService.getObject>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {}; - } - - private saveTitleCache(cache: Record): void { - if (Object.keys(cache).length === 0) { - this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE); - } else { - this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE); - } - } - - private getTitleCacheKey(id: string): string { - return `${chatSessionResourceToId(this.element.sessionResource)}:${id}`; - } - - /** - * Stable id used to persist/restore the generated title. Tool-based blocks - * key off the last tool call id; reasoning-only blocks fall back to the - * thinking part id so their headers also survive a session reload. - */ - private getTitleCacheId(): string | undefined { - const lastTool = this.toolInvocations[this.toolInvocations.length - 1]; - if (lastTool) { - return lastTool.toolCallId; - } - return this.allThinkingParts.find(t => t.id)?.id ?? this.content.id; - } - - private getCachedTitle(id: string): string | undefined { - const entry = this.loadTitleCache()[this.getTitleCacheKey(id)]; - if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) { - return undefined; - } - return entry.title; - } - - private setCachedTitle(id: string, title: string): void { - const cache = this.loadTitleCache(); - const now = Date.now(); - - // Evict expired entries on write - for (const key of Object.keys(cache)) { - if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) { - delete cache[key]; - } - } - - cache[this.getTitleCacheKey(id)] = { title, storedAt: now }; - - // Cap size by dropping oldest entries - const keys = Object.keys(cache); - if (keys.length > TITLE_CACHE_MAX_ENTRIES) { - const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt); - for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) { - delete cache[sorted[i]]; - } - } - - this.saveTitleCache(cache); - } - - private async generateTitleViaLLM(): Promise { - const cts = new CancellationTokenSource(); - const timeout = setTimeout(() => cts.cancel(), 5000); - - try { - const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); - if (!models.length) { - this.setFallbackTitle(); - return; - } - - if (cts.token.isCancellationRequested) { - this.setFallbackTitle(); - return; - } - - let context: string; - if (this.extractedTitles.length > 0) { - context = this.extractedTitles.join(', '); - } else { - context = this.currentThinkingValue.substring(0, 1000); - } - - const prompt = `Summarize the following content in a SINGLE sentence (under 10 words) using past tense. Follow these rules strictly: - - OUTPUT FORMAT: - - MUST be a single sentence - - MUST be under 10 words - - The FIRST word MUST be a past tense verb (e.g. "Updated", "Reviewed", "Created", "Searched", "Analyzed") - - No quotes, no trailing punctuation - - GENERAL: - - The content may include tool invocations (file edits, reads, searches, terminal commands), reasoning headers, or raw thinking text - - For reasoning headers or thinking text (no tool calls), summarize WHAT was considered/analyzed, NOT that thinking occurred - - For thinking-only summaries, use phrases like: "Considered...", "Planned...", "Analyzed...", "Reviewed..." - - TOOL NAME FILTERING: - - NEVER include tool names like "Replace String in File", "Multi Replace String in File", "Create File", "Read File", etc. in the output - - If an action says "Edited X and used Replace String in File", output ONLY the action on X - - Tool names describe HOW something was done, not WHAT was done - always omit them - - VOCABULARY - Use varied synonyms for natural-sounding summaries: - - For edits: "Updated", "Modified", "Changed", "Refactored", "Fixed", "Adjusted" - - For reads: "Reviewed", "Examined", "Checked", "Inspected", "Analyzed", "Explored" - - For creates: "Created", "Added", "Generated" - - For searches: "Searched for", "Looked up", "Investigated" - - For terminal: "Ran command", "Executed" - - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" - - Choose the synonym that best fits the context - -${this.hookCount > 0 ? `BLOCKED/DENIED CONTENT (hooks detected): - - Only mention "blocked" if the content explicitly includes hook results that blocked or warned about a tool (e.g. "Blocked terminal" or "Warning for read_file") - - If blocked items are present alongside normal tool calls, briefly note the block but do NOT let it dominate the summary: e.g. "Updated file.ts, blocked terminal" - - ` : `IMPORTANT: Do NOT use words like "blocked", "denied", or "tried" in the summary - there are no hooks or blocked items in this content. Just summarize normally. - - `}RULES FOR TOOL CALLS: - 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " - 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) - 3. If exactly ONE file was read: Start with a read synonym + "" (include actual filename) - 4. If MULTIPLE files were edited: Start with an edit synonym + "X files" - 5. If MULTIPLE files were read: Start with a read synonym + "X files" - 6. If BOTH edits AND reads occurred on DIFFERENT files: Combine them naturally - 7. For searches: Say "searched for " or "looked up " with the actual search term, NOT "searched for files" - 8. After the file info, you may add a brief summary of other actions if space permits - 9. NEVER say "1 file" - always use the actual filename when there's only one file - - RULES FOR REASONING HEADERS (no tool calls): - 1. If the input contains reasoning/analysis headers without actual tool invocations, summarize the main topic and what was considered - 2. Use past tense verbs that indicate thinking, not doing: "Considered", "Planned", "Analyzed", "Evaluated" - 3. Focus on WHAT was being thought about, not that thinking occurred - - RULES FOR RAW THINKING TEXT: - 1. Extract the main topic or question being considered from the text - 2. Identify any specific files, functions, or concepts mentioned - 3. Summarize as "Analyzed " or "Considered " - 4. If discussing code structure: "Reviewed " - 5. If discussing a problem: "Analyzed " - 6. If discussing implementation: "Planned " - - EXAMPLES WITH TOOLS: - - "Read HomePage.tsx, Edited HomePage.tsx" → "Reviewed and updated HomePage.tsx" - - "Edited HomePage.tsx" → "Updated HomePage.tsx" - - "Edited config.css and used Replace String in File" → "Modified config.css" - - "Edited App.tsx, used Multi Replace String in File" → "Refactored App.tsx" - - "Read config.json, Read package.json" → "Reviewed 2 files" - - "Edited App.tsx, Read utils.ts" → "Updated App.tsx and checked utils.ts" - - "Edited App.tsx, Read utils.ts, Read types.ts" → "Updated App.tsx and reviewed 2 files" - - "Edited index.ts, Edited styles.css, Ran terminal command" → "Modified 2 files and ran command" - - "Read README.md, Searched for AuthService" → "Checked README.md and searched for AuthService" - - "Searched for login, Searched for authentication" → "Searched for login and authentication" - - "Edited api.ts, Edited models.ts, Read schema.json" → "Updated 2 files and reviewed schema.json" - - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" - - "Searched codebase for error handling" → "Looked up error handling" - -${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): - - "Blocked terminal, Edited config.ts" → "Edited config.ts, terminal was blocked" - - "Blocked terminal, Blocked read_file" → "Two tools were blocked by hooks" - - "Warning for read_file, Edited utils.ts" → "Edited utils.ts with a hook warning" - - ` : ''}EXAMPLES WITH REASONING HEADERS (no tools): - - "Analyzing component architecture" → "Considered component architecture" - - "Planning refactor strategy" → "Planned refactor strategy" - - "Reviewing error handling approach, Considering edge cases" → "Analyzed error handling approach" - - "Understanding the codebase structure" → "Reviewed codebase structure" - - "Thinking about implementation options" → "Considered implementation options" - - EXAMPLES WITH RAW THINKING TEXT: - - "I need to understand how the authentication flow works in this app..." → "Analyzed authentication flow" - - "Let me think about how to refactor this component to be more maintainable..." → "Planned component refactoring" - - "The error seems to be coming from the database connection..." → "Investigated database connection issue" - - "Looking at the UserService class, I see it handles..." → "Reviewed UserService implementation" - - Content: ${context}`; - - const response = await this.languageModelsService.sendChatRequest( - models[0], - undefined, - [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], - {}, - cts.token - ); - - let generatedTitle = ''; - for await (const part of response.stream) { - if (cts.token.isCancellationRequested) { - break; - } - if (Array.isArray(part)) { - for (const p of part) { - if (p.type === 'text') { - generatedTitle += p.value; - } - } - } else if (part.type === 'text') { - generatedTitle += part.value; - } - } - - if (cts.token.isCancellationRequested) { - this.setFallbackTitle(); - return; - } - - await response.result; - generatedTitle = generatedTitle.trim(); - - if (generatedTitle.includes('can\'t assist with that')) { - this.setFallbackTitle(); - return; - } - - if (generatedTitle && !this._store.isDisposed) { - this.currentTitle = generatedTitle; - this.setFinalizedTitle(generatedTitle); - this.content.generatedTitle = generatedTitle; - this.setGeneratedTitleOnAllParts(generatedTitle); - - // Persist to storage for non-local sessions only - if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { - const cacheId = this.getTitleCacheId(); - if (cacheId) { - this.setCachedTitle(cacheId, generatedTitle); - } - } - - return; - } - } catch (error) { - // fall through to default title - } finally { - clearTimeout(timeout); - cts.dispose(); - } - - this.setFallbackTitle(); - } - - private restoreSingleItemToOriginalPosition(): boolean { - if (!this.singleItemInfo) { - return false; - } - - const { element, thinkingWrapper, originalParent, originalNextSibling, restoreToOriginalParent, toolInvocation } = this.singleItemInfo; - - const hasOtherThinkingItems = this.wrapper && Array.from(this.wrapper.children).some(child => - child !== thinkingWrapper && child !== this.workingSpinnerElement - ); - if (hasOtherThinkingItems) { - this.singleItemInfo = undefined; - return false; - } - - const precedingToolInvocationPart = isHTMLElement(originalNextSibling) && originalNextSibling.parentElement === originalParent - ? originalNextSibling.previousElementSibling - : originalParent.lastElementChild; - if (restoreToOriginalParent) { - if (originalNextSibling && originalNextSibling.parentNode === originalParent) { - originalParent.insertBefore(element, originalNextSibling); - } else { - originalParent.appendChild(element); - } - } else if (precedingToolInvocationPart?.classList.contains('chat-tool-invocation-part')) { - precedingToolInvocationPart.appendChild(element); - } else if (originalNextSibling && originalNextSibling.parentNode === originalParent) { - originalParent.insertBefore(element, originalNextSibling); - } else { - originalParent.appendChild(element); - } - thinkingWrapper.remove(); - - if (toolInvocation) { - this.toolWrappersByCallId.delete(toolInvocation.toolCallId); - this.toolIconsByCallId.delete(toolInvocation.toolCallId); - toolInvocation.isAttachedToThinking = false; - } - - hide(this.domNode); - this.singleItemInfo = undefined; - return true; - } - - private updateAggregatedDiff(): void { - let totalAdded = 0; - let totalRemoved = 0; - for (const data of this.diffDataByPartId.values()) { - totalAdded += data.added; - totalRemoved += data.removed; - } - this._aggregatedDiff = { added: totalAdded, removed: totalRemoved }; - - // Re-render the finalized title if streaming is already complete, - // since diff events from edit pills may arrive after the title was set. - if (this.streamingCompleted || this.element.isComplete) { - this.setFinalizedTitle(this.currentTitle); - } - } - - private setFallbackTitle(): void { - const finalLabel = this.appendedItemCount > 0 - ? this.appendedItemCount === 1 - ? localize('chat.thinking.finished.withStepsSingular', 'Finished with 1 step') - : localize('chat.thinking.finished.withStepsPlural', 'Finished with {0} steps', this.appendedItemCount) - : localize('chat.thinking.finished', 'Finished Working'); - - this.currentTitle = finalLabel; - // With lazy rendering, wrapper may not be created yet if content hasn't been expanded - if (this.wrapper) { - this.wrapper.classList.remove('chat-thinking-streaming'); - } - this.domNode.classList.remove('chat-thinking-active'); - this.streamingCompleted = true; - - // Render any aggregated images that were deferred during fixed scrolling streaming. - this.flushPendingExternalResources(); - - if (this._collapseButton) { - this._collapseButton.icon = Codicon.checkCompact; - this.setFinalizedTitle(finalLabel); - } - - this.updateDropdownClickability(); - } - - /** - * Appends a tool invocation or content item to the thinking group. - * The factory is called lazily - only when the thinking section is expanded. - * If already expanded, the factory is called immediately. - * - * When the caller has already created the content part eagerly (for example, a - * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass - * that part as `eagerDisposable` so it is registered on this thinking part - * immediately. Otherwise, if the thinking section is collapsed and the lazy item - * is never materialized (because the user never expands it), the eagerly-created - * part would leak: its disposable is only referenced from inside the factory's - * closure, which nothing ever calls. - */ - public appendItem( - factory: () => { domNode: HTMLElement; disposable?: IDisposable }, - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata, - originalParent?: HTMLElement, - onDidChangeDiff?: Event, - eagerDisposable?: IDisposable, - ): void { - this.processPendingRemovals(); - this.containsGroupedItems = true; - - // Track tool invocation metadata immediately (for title generation) - this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); - this.updateWorkingSpinnerVisibility(); - this.appendedItemCount++; - - // Listen for diff changes from edit pills - if (onDidChangeDiff && toolInvocationId) { - this.diffDataByPartId.set(toolInvocationId, { added: 0, removed: 0, resources: [] }); - this._register(onDidChangeDiff(data => { - this.diffDataByPartId.set(toolInvocationId, data); - this.updateAggregatedDiff(); - })); - } - - // Register any caller-owned disposable up-front so it is always cleaned up - // with this thinking part, even if the lazy item is never materialized. - if (eagerDisposable) { - this._register(eagerDisposable); - } - - // get random message based on tool type - if (this.workingSpinnerLabel) { - const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); - } - - // If expanded or has been expanded once, render immediately - if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { - const result = factory(); - this.appendItemToDOM(result.domNode, toolInvocationId, toolInvocationOrMarkdown, originalParent); - if (result.disposable) { - const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } else { - // Defer rendering until expanded - const item: ILazyToolItem = { - kind: 'tool', - lazy: new Lazy(factory), - toolInvocationId, - toolInvocationOrMarkdown, - originalParent, - isHook: !toolInvocationOrMarkdown && !!toolInvocationId, - }; - this.lazyItems.push(item); - } - - this.updateDropdownClickability(); - } - - public removeMaterializedItem(toolCallId: string): void { - this.toolDisposables.deleteAndDispose(toolCallId); - this.ownedToolParts.delete(toolCallId); - - const wrapper = this.toolWrappersByCallId.get(toolCallId); - if (wrapper) { - this.toolWrappersByCallId.delete(toolCallId); - this.toolIconsByCallId.delete(toolCallId); - } - - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); - - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId - ); - if (toolInvocationsIndex !== -1) { - // Use the tracked displayed label (which may differ from invocationMessage - // for streaming edit tools that show "Editing files") - const label = this.toolLabelsByCallId.get(toolCallId); - if (label) { - const titleIndex = this.extractedTitles.indexOf(label); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - } - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - this.toolLabelsByCallId.delete(toolCallId); - - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - - this.updateWorkingSpinnerVisibility(); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - - /** - * Removes a markdown edit pill child by its part ID (codeblocksPartId). - */ - public removeEditPillByPartId(partId: string): void { - let removed = false; - - const lazyIndex = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === partId); - if (lazyIndex !== -1) { - this.lazyItems.splice(lazyIndex, 1); - removed = true; - } - - if (this.diffDataByPartId.delete(partId)) { - this.updateAggregatedDiff(); - removed = true; - } - - if (removed) { - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - } - - /** - * removes/re-establishes a lazy item from the thinking container - * this is needed so we can check if there are confirmations still needed - */ - public removeLazyItem(toolInvocationId: string): boolean { - const index = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === toolInvocationId); - if (index === -1) { - return false; - } - - const removedItem = this.lazyItems[index]; - this.lazyItems.splice(index, 1); - this.appendedItemCount--; - if (removedItem.kind === 'tool' && removedItem.isHook) { - this.hookCount = Math.max(0, this.hookCount - 1); - } else { - this.toolInvocationCount--; - } - - // Clear the attached-to-thinking flag on the removed tool invocation - if (removedItem.kind === 'tool' && removedItem.toolInvocationOrMarkdown && (removedItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { - removedItem.toolInvocationOrMarkdown.isAttachedToThinking = false; - - // Keep extractedTitles in sync when a lazy tool leaves the thinking container. - // Use the tracked displayed label (which may differ from invocationMessage - // for streaming edit tools that show "Editing files") - const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - const label = this.toolLabelsByCallId.get(toolCallId); - if (label) { - const titleIndex = this.extractedTitles.indexOf(label); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - } - this.toolLabelsByCallId.delete(toolCallId); - } - - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolId === toolInvocationId - ); - if (toolInvocationsIndex !== -1) { - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - - this.updateDropdownClickability(); - this.updateWorkingSpinnerVisibility(); - return true; - } - - private processPendingRemovals(): void { - this.pendingRemovalFlushDisposable?.dispose(); - this.pendingRemovalFlushDisposable = undefined; - - if (this.pendingRemovals.length === 0) { - return; - } - - const pendingRemovals = this.pendingRemovals; - this.pendingRemovals = []; - - for (const pending of pendingRemovals) { - this.removeStreamingToolEntry(pending.toolCallId, pending.toolLabel); - } - } - - private schedulePendingRemovalsFlush(): void { - if (this.pendingRemovalFlushDisposable) { - return; - } - - this.pendingRemovalFlushDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { - this.pendingRemovalFlushDisposable = undefined; - if (this._store.isDisposed) { - return; - } - - this.processPendingRemovals(); - }); - } - - // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. - private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { - this.toolDisposables.deleteAndDispose(toolCallId); - this.ownedToolParts.get(toolCallId)?.dispose(); - this.ownedToolParts.delete(toolCallId); - - const wrapper = this.toolWrappersByCallId.get(toolCallId); - if (wrapper) { - wrapper.remove(); - this.toolWrappersByCallId.delete(toolCallId); - this.toolIconsByCallId.delete(toolCallId); - } - - // make sure to remove any lazy item as well - const lazyIndex = this.lazyItems.findIndex(item => - item.kind === 'tool' && - item.toolInvocationOrMarkdown && - (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && - item.toolInvocationOrMarkdown.toolCallId === toolCallId - ); - if (lazyIndex !== -1) { - const removedLazyItem = this.lazyItems[lazyIndex]; - if (removedLazyItem.kind === 'tool' && removedLazyItem.toolInvocationOrMarkdown && (removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { - removedLazyItem.toolInvocationOrMarkdown.isAttachedToThinking = false; - } - this.lazyItems.splice(lazyIndex, 1); - } - - this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); - this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); - const toolInvocationsIndex = this.toolInvocations.findIndex(t => - (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId - ); - if (toolInvocationsIndex !== -1) { - this.toolInvocations.splice(toolInvocationsIndex, 1); - } - - const titleIndex = this.extractedTitles.indexOf(toolLabel); - if (titleIndex !== -1) { - this.extractedTitles.splice(titleIndex, 1); - } - this.toolLabelsByCallId.delete(toolCallId); - this._pendingExternalResources.delete(toolCallId); - this._externalResourceWidget.removeToolInvocation(toolCallId); - this.updateWorkingSpinnerVisibility(); - this.updateDropdownClickability(); - this._onDidChangeHeight.fire(); - } - - private trackToolMetadata( - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata - ): void { - if (!toolInvocationId) { - return; - } - - // Track hooks separately: if toolInvocationOrMarkdown is undefined, it's a hook item - const isHook = !toolInvocationOrMarkdown; - if (isHook) { - this.hookCount++; - } else { - this.toolInvocationCount++; - } - - // Shift default title from 'Thinking' to 'Working' once we have tool calls - if (this.toolInvocationCount === 1) { - this.defaultTitle = this.workingTitle; - } - - let toolCallLabel: string; - let toolCallTitle: ChatThinkingTitle; - - const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); - if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { - const invocationMessage = toolInvocationOrMarkdown.invocationMessage; - - // For edit-type tools that are still streaming, use a friendlier label - // instead of the generic tool display name (e.g. "Replace String in File") - const isStreamingEditTool = toolInvocationOrMarkdown.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocationOrMarkdown) && isGenericEditToolId(toolInvocationOrMarkdown.toolId); - if (isStreamingEditTool) { - toolCallTitle = localize('chat.thinking.editingFiles', 'Editing files'); - } else { - toolCallTitle = invocationMessage; - } - toolCallLabel = getThinkingTitleValue(toolCallTitle); - - this.toolInvocations.push(toolInvocationOrMarkdown); - - // Track the displayed label for consistent cleanup - const toolCallId = toolInvocationOrMarkdown.toolCallId; - this.toolLabelsByCallId.set(toolCallId, toolCallLabel); - - // Render external image pills for serialized (already-completed) tool invocations - if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { - this.updateExternalResourceParts(toolInvocationOrMarkdown); - - // Queue hidden serialized tools for removal immediately. - if (IChatToolInvocation.isEffectivelyHidden(toolInvocationOrMarkdown)) { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: toolCallLabel }); - this.schedulePendingRemovalsFlush(); - } - } - - // track state for live/still streaming tools, excluding serialized tools - if (toolInvocationOrMarkdown.kind === 'toolInvocation') { - let currentToolLabel = toolCallLabel; - let isComplete = false; - let isStreaming = IChatToolInvocation.isStreaming(toolInvocationOrMarkdown); - - const toolStore = new DisposableStore(); - this.toolDisposables.set(toolInvocationOrMarkdown.toolCallId, toolStore); - - const updateTitle = (updatedTitle: ChatThinkingTitle) => { - const updatedMessage = getThinkingTitleValue(updatedTitle); - if (updatedMessage && !thinkingTitleEqual(updatedTitle, toolCallTitle)) { - // replace old title if exists, otherwise add new - if (updatedMessage !== currentToolLabel) { - const oldIndex = this.extractedTitles.indexOf(currentToolLabel); - const updatedIndex = this.extractedTitles.indexOf(updatedMessage); - - if (oldIndex !== -1) { - if (updatedIndex !== -1 && updatedIndex !== oldIndex) { - this.extractedTitles.splice(oldIndex, 1); - } else { - this.extractedTitles[oldIndex] = updatedMessage; - } - } else if (updatedIndex === -1) { - this.extractedTitles.push(updatedMessage); - } - currentToolLabel = updatedMessage; - } - toolCallLabel = updatedMessage; - toolCallTitle = updatedTitle; - this.toolLabelsByCallId.set(toolCallId, updatedMessage); - this.lastExtractedTitle = updatedMessage; - - // make sure not to set title if expanded - if (!this.fixedScrollingMode && !this._isExpanded.read(undefined)) { - this.setTitle(updatedTitle); - } - } - }; - - const autorunDisposable = autorun(reader => { - if (isComplete) { - return; - } - - const currentState = toolInvocationOrMarkdown.state.read(reader); - this.updateWorkingSpinnerVisibility(reader); - - // queue item to be removed if it was streaming and presentation is hidden - if (isStreaming && currentState.type !== IChatToolInvocation.StateKind.Streaming) { - isStreaming = false; - - // Update terminal tool icon based on sandbox wrapping state - const termData = toolInvocationOrMarkdown.toolSpecificData as IChatTerminalToolInvocationData | undefined; - if (termData?.kind === 'terminal') { - const iconEl = this.toolIconsByCallId.get(toolCallId); - if (iconEl) { - const newIcon = termData.commandLine?.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal; - setThinkingIcon(iconEl, newIcon); - } - } - - if (toolInvocationOrMarkdown.presentation === 'hidden') { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); - this.schedulePendingRemovalsFlush(); - isComplete = true; - return; - } - } - - if (currentState.type === IChatToolInvocation.StateKind.Completed || - currentState.type === IChatToolInvocation.StateKind.Cancelled) { - // Remove tools that should be hidden now or after completion. - if (toolInvocationOrMarkdown.presentation === 'hidden' || toolInvocationOrMarkdown.presentation === 'hiddenAfterComplete') { - this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); - this.schedulePendingRemovalsFlush(); - } - - // Render image pills outside the collapsible area for completed tools - if (currentState.type === IChatToolInvocation.StateKind.Completed) { - this.updateExternalResourceParts(toolInvocationOrMarkdown); - const completedMessage = toolInvocationOrMarkdown.pastTenseMessage ?? toolInvocationOrMarkdown.invocationMessage; - const completedText = typeof completedMessage === 'string' ? completedMessage : completedMessage.value; - const iconElement = this.toolIconsByCallId.get(toolCallId); - if (iconElement && isNoProblemsFoundResult(toolInvocationOrMarkdown.toolId, completedText)) { - setThinkingIcon(iconElement, Codicon.search); - } - } - - isComplete = true; - return; - } - - // streaming - if (currentState.type === IChatToolInvocation.StateKind.Streaming) { - isStreaming = true; - const streamingMessage = currentState.streamingMessage.read(reader); - if (streamingMessage) { - updateTitle(streamingMessage); - } - return; - } - - // executing (something like `Replacing 67 lines.....`) - if (currentState.type === IChatToolInvocation.StateKind.Executing) { - const progressData = currentState.progress.read(reader); - if (progressData.message) { - updateTitle(progressData.message); - } else { - const invocationMsg = toolInvocationOrMarkdown.invocationMessage; - if (invocationMsg) { - updateTitle(invocationMsg); - } - } - return; - } - - // confirmations, failures, completed, other, etc - const invocationMsg = toolInvocationOrMarkdown.invocationMessage; - if (invocationMsg) { - updateTitle(invocationMsg); - } - }); - toolStore.add(autorunDisposable); - } - } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { - const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); - if (codeblockInfo?.uri) { - const filename = basename(codeblockInfo.uri); - toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); - } else { - toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); - } - toolCallTitle = toolCallLabel; - } else if (toolInvocationOrMarkdown?.kind === 'externalEdit') { - const filename = basename(toolInvocationOrMarkdown.uri); - switch (toolInvocationOrMarkdown.editKind) { - case 'create': - toolCallLabel = localize('chat.thinking.createdFile', 'Created {0}', filename); - break; - case 'delete': - toolCallLabel = localize('chat.thinking.deletedFile', 'Deleted {0}', filename); - break; - case 'rename': - toolCallLabel = localize('chat.thinking.renamedFile', 'Renamed {0}', filename); - break; - case 'edit': - toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); - break; - } - toolCallTitle = toolCallLabel; - } else { - toolCallLabel = toolInvocationId; - toolCallTitle = toolCallLabel; - } - - // Add tool call to extracted titles for LLM title generation - if (!this.extractedTitles.includes(toolCallLabel)) { - this.extractedTitles.push(toolCallLabel); - } - - this.lastExtractedTitle = toolCallLabel; - - if (!this.fixedScrollingMode && !this._isExpanded.get()) { - this.setTitle(toolCallTitle); - } - } - - private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { - if (toolInvocation.toolSpecificData?.kind === 'terminal') { - return; - } - - // In fixed scrolling mode, defer rendering aggregated images at the bottom while - // the response is still streaming. The images would otherwise overlap the pinned - // scrolling viewport. They are flushed once streaming completes. - if (this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete) { - this._pendingExternalResources.set(toolInvocation.toolCallId, toolInvocation); - return; - } - - const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); - if (extractedImages.length === 0) { - return; - } - - const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ - kind: 'data', - value: image.data.buffer, - mimeType: image.mimeType, - uri: image.uri, - })); - - this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); - } - - private flushPendingExternalResources(): void { - if (this._pendingExternalResources.size === 0) { - return; - } - const pending = Array.from(this._pendingExternalResources.values()); - this._pendingExternalResources.clear(); - for (const toolInvocation of pending) { - this.updateExternalResourceParts(toolInvocation); - } - } - - private appendItemToDOM( - content: HTMLElement, - toolInvocationId?: string, - toolInvocationOrMarkdown?: ChatThinkingItemMetadata, - originalParent?: HTMLElement - ): void { - if (!content.hasChildNodes() || content.textContent?.trim() === '') { - return; - } - - const itemWrapper = $('.chat-thinking-tool-wrapper'); - const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; - const isExternalEdit = toolInvocationOrMarkdown?.kind === 'externalEdit'; - const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const isSearchTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'search'; - const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; - - let icon: ThemeIcon; - if (isNoProblemsFoundResult(toolInvocationId, content.textContent ?? undefined)) { - icon = Codicon.search; - } else if (isMarkdownEdit || isExternalEdit) { - icon = Codicon.pencil; - } else if (isSearchTool) { - icon = Codicon.search; - } else if (isTerminalTool) { - const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number }; commandLine?: { isSandboxWrapped?: boolean } }; - const exitCode = terminalData?.terminalCommandState?.exitCode; - const isSandboxWrapped = terminalData?.commandLine?.isSandboxWrapped; - if (exitCode !== undefined && exitCode !== 0) { - icon = Codicon.error; - } else if (isSandboxWrapped) { - icon = Codicon.terminalSecure; - } else { - icon = toolInvocationIcon ?? Codicon.terminal; - } - } else if (content.classList.contains('chat-hook-outcome-blocked')) { - icon = Codicon.error; - } else if (content.classList.contains('chat-hook-outcome-warning')) { - icon = Codicon.warning; - } else { - icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon, content.textContent ?? undefined) : Codicon.tools; - } - - const iconElement = createThinkingIcon(icon); - itemWrapper.appendChild(iconElement); - itemWrapper.appendChild(content); - - if (this.toolInvocationCount === 1 && this.hookCount === 0 && originalParent) { - const toolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown : undefined; - this.singleItemInfo = { - element: content, - thinkingWrapper: itemWrapper, - originalParent, - originalNextSibling: this.domNode, - restoreToOriginalParent: !!toolInvocation || isExternalEdit, - toolInvocation - }; - } else { - this.singleItemInfo = undefined; - } - - const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); - if (isToolInvocation && toolInvocationOrMarkdown.toolCallId) { - this.toolWrappersByCallId.set(toolInvocationOrMarkdown.toolCallId, itemWrapper); - this.toolIconsByCallId.set(toolInvocationOrMarkdown.toolCallId, iconElement); - } - - this.appendToWrapper(itemWrapper); - - if (this.fixedScrollingMode && this.scrollableElement) { - // Observe the child wrapper for resizes (e.g. terminal expanding) - if (this.childResizeObserver && !this.streamingCompleted) { - const observeDisposable = this.childResizeObserver.observe(itemWrapper); - const toolCallId = isToolInvocation ? toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - let store = this.toolDisposables.get(toolCallId); - if (!store) { - store = new DisposableStore(); - this.toolDisposables.set(toolCallId, store); - } - store.add(observeDisposable); - } else { - this._register(observeDisposable); - } - } - - // Coalesce reads of scrollHeight to avoid forced reflows when many items - // are appended in the same tick (e.g. when restoring a session). - this.scheduleAppendRefresh(); - } - } - - private scheduleAppendRefresh(): void { - if (this._pendingAppendRefresh.value) { - return; - } - this._pendingAppendRefresh.value = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { - this._pendingAppendRefresh.clear(); - if (this._store.isDisposed) { - return; - } - this.refreshContentHeight(); - this.updateScrollDimensionsFromCache(); - }); - } - - private materializeLazyItem(item: ILazyItem): void { - if (item.kind === 'thinking') { - // Materialize thinking container - this.appendToWrapper(item.textContainer); - // Store reference to textContainer for updateThinking calls - this.textContainer = item.textContainer; - this.id = item.content.id; - // Use item.content which is kept up-to-date during streaming via updateThinking - this.updateThinking(item.content); - return; - } - - if (this.workingSpinnerLabel) { - const isTerminalTool = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && item.toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; - const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); - } - - // Handle tool items - if (item.lazy.hasValue) { - // Already evaluated — but may not have been placed in the DOM yet - // (e.g. finalizeTitleIfDefault materialized it before the wrapper existed). - const result = item.lazy.value; - if (!result.domNode.parentElement) { - this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); - } - return; - } - - const result = item.lazy.value; - this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); - - if (result.disposable) { - const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; - if (toolCallId) { - this.ownedToolParts.set(toolCallId, result.disposable); - } else { - this._register(result.disposable); - } - } - } - - // makes a new text container. when we update, we now update this container. - public setupThinkingContainer(content: IChatThinkingPart) { - // Avoid creating new containers after disposal - if (this._store.isDisposed) { - return; - } - this.appendedItemCount++; - this.allThinkingParts.push(content); - const contentText = extractTextFromPart(content); - this.recordReasoningContent(contentText); - // First-writer wins: a later grouped block can be the first multi-header - // summary (when earlier blocks had <2 headers), so track it here too — the - // lazy/reload path never routes through updateThinking. - this.trackDroppedSummaryHeader(contentText); - this.textContainer = $('.chat-thinking-item.markdown-content'); - // Observe the new textContainer for child resizes in fixed scrolling mode - if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { - this._register(this.childResizeObserver.observe(this.textContainer)); - } - if (content.value) { - // Use lazy rendering when collapsed to preserve order with tool items - if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { - // Render immediately when expanded - this.appendToWrapper(this.textContainer); - this.id = content.id; - this.updateThinking(content); - } else { - // Update this.content and this.id so that subsequent updateThinking calls - // or materializeLazyItem will use the correct content for this section - this.content = content; - this.id = content.id; - // Defer rendering until expanded to preserve order - const lazyThinking: ILazyThinkingItem = { - kind: 'thinking', - textContainer: this.textContainer, - content - }; - this.lazyItems.push(lazyThinking); - } - - if (this.workingSpinnerLabel) { - this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); - } - } - this.updateDropdownClickability(); - } - - protected override setTitle(title: ChatThinkingTitle, omitPrefix?: boolean): void { - const titleValue = getThinkingTitleValue(title); - if (!titleValue || this.element.isComplete) { - return; - } - - if (omitPrefix) { - this.clearTitleDetail(); - if (this._collapseButton) { - const labelElement = this._collapseButton.labelElement; - labelElement.textContent = ''; - const plainSpan = $('span'); - plainSpan.textContent = titleValue; - labelElement.appendChild(plainSpan); - this._collapseButton.element.ariaLabel = titleValue; - } - this.forgetShimmerTitle(); - this.currentTitle = titleValue; - return; - } - - this.lastExtractedTitle = titleValue; - this.lastRenderedTitle = title; - this.currentTitle = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, titleValue); - - if (!this._collapseButton) { - return; - } - - const labelElement = this._collapseButton.labelElement; - - this.setShimmerTitle(localize('chat.thinking.shimmer', "{0}: ", this.defaultTitle)); - - // Dispose previous detail rendering - this._titleDetailRendered.clear(); - this._titleFileWidgetStore.clear(); - - const markdownTitle = typeof title === 'string' ? new MarkdownString(title) : title; - const result = this.chatContentMarkdownRenderer.render(markdownTitle); - result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); - renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); - this._titleFileWidgetStore.add(addDisposableListener(result.element, EventType.CLICK, event => { - if (isHTMLElement(event.target) && event.target.closest('a, input')) { - return; - } - EventHelper.stop(event, true); - this.toggleExpanded(); - })); - this._titleDetailRendered.value = result; - - const previousTitleDetail = this.titleDetailContainer; - // eslint-disable-next-line no-restricted-syntax - const hasTitleLinks = result.element.querySelector('a') !== null; - if (hasTitleLinks) { - const container = this._collapseButton.element.parentElement; - if (container) { - if (this._hoverChevron) { - container.appendChild(this._hoverChevron); - } - container.insertBefore(result.element, this.diffButton?.element ?? this._hoverChevron ?? null); - } - } else { - labelElement.appendChild(result.element); - if (!this.diffButton && this._hoverChevron) { - this._collapseButton.element.appendChild(this._hoverChevron); - } - } - previousTitleDetail?.remove(); - this.titleDetailContainer = result.element; - - const renderedTitle = result.element.textContent?.trim() || titleValue; - const thinkingLabel = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, renderedTitle); - this._collapseButton.element.ariaLabel = thinkingLabel; - this._collapseButton.element.ariaExpanded = String(this.isExpanded()); - } - - private clearTitleDetail(): void { - this.titleDetailContainer?.remove(); - this.titleDetailContainer = undefined; - this._titleDetailRendered.clear(); - this._titleFileWidgetStore.clear(); - } - - hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { - - if (_element.isComplete) { - return true; - } - if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') - && other.toolSpecificData?.kind === 'subagent' - && !other.subAgentInvocationId) { - return false; - } - - if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { - return true; - } - - if (other.kind !== 'thinking') { - return false; - } - - return other?.id !== this.id; - } - - override dispose(): void { - this.isActive = false; - if (this.workingSpinnerElement) { - this.workingSpinnerElement.remove(); - this.workingSpinnerElement = undefined; - this.workingSpinnerLabel = undefined; - } - this.pendingRemovalFlushDisposable?.dispose(); - this.pendingRemovalFlushDisposable = undefined; - this.pendingScrollDisposable?.dispose(); - super.dispose(); - } -} +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { $, addDisposableListener, clearNode, DisposableResizeObserver, EventHelper, EventType, getWindow, hide, isHTMLElement, scheduleAtNextAnimationFrame } from '../../../../../../base/browser/dom.js'; +import { alert } from '../../../../../../base/browser/ui/aria/aria.js'; +import { Button } from '../../../../../../base/browser/ui/button/button.js'; +import { HoverStyle } from '../../../../../../base/browser/ui/hover/hover.js'; +import { DomScrollableElement } from '../../../../../../base/browser/ui/scrollbar/scrollableElement.js'; +import { ScrollbarVisibility } from '../../../../../../base/common/scrollable.js'; +import { IChatExternalEdit, IChatMarkdownContent, IChatTerminalToolInvocationData, IChatThinkingPart, IChatToolInvocation, IChatToolInvocationSerialized } from '../../../common/chatService/chatService.js'; +import { IChatContentPart, IChatContentPartDiffData, IChatContentPartDiffResource, IChatContentPartRenderContext } from './chatContentParts.js'; +import { IChatRendererContent } from '../../../common/model/chatViewModel.js'; +import { ChatConfiguration, ThinkingDisplayMode } from '../../../common/constants.js'; +import { ChatTreeItem } from '../../chat.js'; +import { IInstantiationService } from '../../../../../../platform/instantiation/common/instantiation.js'; +import { IConfigurationService } from '../../../../../../platform/configuration/common/configuration.js'; +import { IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js'; +import { AccessibilityWorkbenchSettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js'; +import { IMarkdownString, MarkdownString, markdownStringEqual } from '../../../../../../base/common/htmlContent.js'; +import { IRenderedMarkdown } from '../../../../../../base/browser/markdownRenderer.js'; +import { IMarkdownRenderer } from '../../../../../../platform/markdown/browser/markdownRenderer.js'; +import { extractCodeblockUrisFromText } from '../../../common/widget/annotations.js'; +import { basename, getComparisonKey } from '../../../../../../base/common/resources.js'; +import { URI } from '../../../../../../base/common/uri.js'; +import { ChatThinkingStyleContentPart, createThinkingIcon } from './chatThinkingStyleContentPart.js'; +export { createThinkingIcon }; +import { renderFileWidgets } from './chatInlineAnchorWidget.js'; +import { localize } from '../../../../../../nls.js'; +import { Codicon } from '../../../../../../base/common/codicons.js'; +import { ThemeIcon } from '../../../../../../base/common/themables.js'; +import { Lazy } from '../../../../../../base/common/lazy.js'; +import { Emitter, Event } from '../../../../../../base/common/event.js'; +import { DisposableMap, DisposableStore, IDisposable, MutableDisposable, toDisposable } from '../../../../../../base/common/lifecycle.js'; +import { autorun, IReader } from '../../../../../../base/common/observable.js'; +import { CancellationTokenSource } from '../../../../../../base/common/cancellation.js'; +import { IChatMarkdownAnchorService } from './chatMarkdownAnchorService.js'; +import { ChatMessageRole, ILanguageModelsService } from '../../../common/languageModels.js'; +import './media/chatThinkingContent.css'; +import { IHoverService } from '../../../../../../platform/hover/browser/hover.js'; +import { ITelemetryService } from '../../../../../../platform/telemetry/common/telemetry.js'; +import { getCompactCodicon } from '../../chatIcons.js'; +import { IStorageService, StorageScope, StorageTarget } from '../../../../../../platform/storage/common/storage.js'; +import { IEditorService } from '../../../../../services/editor/common/editorService.js'; +import { extractImagesFromToolInvocationOutputDetails } from '../../../common/chatImageExtraction.js'; +import { IChatCollapsibleIODataPart } from './chatToolInputOutputContentPart.js'; +import { ChatThinkingExternalResourceWidget } from './chatThinkingExternalResourcesWidget.js'; +import { LocalChatSessionUri, chatSessionResourceToId } from '../../../common/model/chatUri.js'; +import { IEditSessionDiffStats } from '../../../common/editing/chatEditingService.js'; + + +// Context key id mirrored from `vs/sessions/common/contextkeys` (`IsPhoneLayoutContext`). +// Inlined as a string because `vs/workbench` must not import from `vs/sessions`. +const SESSIONS_IS_PHONE_LAYOUT_KEY = 'sessionsIsPhoneLayout'; + +/** + * Read-only chats and phone layouts use collapsed preview regardless of the configured thinking style. + */ +export function getEffectiveThinkingDisplayMode(configurationService: IConfigurationService, contextKeyService: IContextKeyService, readOnly = false): ThinkingDisplayMode { + if (readOnly || contextKeyService.getContextKeyValue(SESSIONS_IS_PHONE_LAYOUT_KEY) === true) { + return ThinkingDisplayMode.CollapsedPreview; + } + return configurationService.getValue('chat.agent.thinkingStyle') ?? ThinkingDisplayMode.Collapsed; +} + +function extractTextFromPart(content: IChatThinkingPart): string { + const raw = Array.isArray(content.value) ? content.value.join('') : (content.value || ''); + return raw.trim(); +} + +function isEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + return lowerToolId.includes('edit') || + lowerToolId.includes('create') || + lowerToolId.includes('replace') || + lowerToolId.includes('patch'); +} + +/** + * Returns true for edit tools whose generic display name should be replaced + * with "Editing files" while streaming (e.g. replace, multi-replace, patch, insertEdit). + * Excludes create and notebook tools which already have good labels. + */ +function isGenericEditToolId(toolId: string): boolean { + const lowerToolId = toolId.toLowerCase(); + if (lowerToolId.includes('create') || lowerToolId.includes('notebook')) { + return false; + } + return lowerToolId.includes('replace') || + lowerToolId.includes('patch') || + lowerToolId.includes('insertedit') || + lowerToolId.includes('insert_edit') || + lowerToolId.includes('editfile'); +} + +function isProblemsToolId(toolId: string | undefined): boolean { + switch (toolId?.toLowerCase()) { + case 'problems': + case 'get_errors': + case 'copilot_geterrors': + return true; + default: + return false; + } +} + +function isNoProblemsFoundResult(toolId: string | undefined, resultText: string | undefined): boolean { + return isProblemsToolId(toolId) && resultText?.toLowerCase().includes('no problems found') === true; +} + +export function getToolInvocationIcon(toolId: string, registeredIcon?: ThemeIcon, resultText?: string): ThemeIcon { + if (isNoProblemsFoundResult(toolId, resultText)) { + return Codicon.search; + } + + if (registeredIcon) { + return registeredIcon; + } + + const lowerToolId = toolId.toLowerCase(); + + if (lowerToolId.includes('comment')) { + return Codicon.comment; + } + + if ( + lowerToolId.includes('search') || + lowerToolId.includes('grep') || + lowerToolId.includes('find') || + lowerToolId.includes('list') || + lowerToolId.includes('semantic') || + lowerToolId.includes('changes') || + lowerToolId.includes('codebase') || + lowerToolId.includes('checked') + ) { + return Codicon.search; + } + + if ( + lowerToolId.includes('read') || + lowerToolId.includes('get_file') || + lowerToolId.includes('problems') + ) { + return Codicon.book; + } + + if (isEditToolId(toolId)) { + return Codicon.pencil; + } + + if ( + lowerToolId.includes('terminal') + ) { + return Codicon.terminal; + } + + // default to generic tool icon + return Codicon.tools; +} + +function setThinkingIcon(iconElement: HTMLElement, icon: ThemeIcon): void { + iconElement.className = 'chat-thinking-icon'; + iconElement.classList.add(...ThemeIcon.asClassNameArray(getCompactCodicon(icon))); +} + +function extractTitleFromThinkingContent(content: string): string | undefined { + const headerMatch = content.match(/^\*\*([^*]+)\*\*/); + return headerMatch ? headerMatch[1] : undefined; +} + +/** A line that is entirely a bold span, e.g. `**Analyzing the request**`. */ +function isThinkingHeaderLine(line: string): boolean { + return /^\s*\*\*.+\*\*\s*$/.test(line); +} + +/** Strips the surrounding `**` when the whole text is a single bold span, so a standalone header renders as plain text. */ +function stripStandaloneBold(text: string): string { + const trimmed = text.trim(); + if (trimmed.startsWith('**') && trimmed.indexOf('**', 2) === trimmed.length - 2) { + return trimmed.slice(2, -2); + } + return text; +} + +/** + * Splits a reasoning-summary value into one markdown string per display row. + * Rows are delimited by bold header lines. When {@link dropLeadingHeader} is set + * and the value starts with a header, that header is dropped because it is + * surfaced as the collapsible title. Returns `undefined` unless the value has at + * least two header lines, so ordinary reasoning prose keeps single-block rendering. + */ +export function splitReasoningSummaryRows(text: string, dropLeadingHeader = true): string[] | undefined { + const sections: { isHeader: boolean; lines: string[] }[] = []; + for (const line of text.split('\n')) { + if (isThinkingHeaderLine(line)) { + sections.push({ isHeader: true, lines: [line] }); + } else if (sections.length === 0) { + sections.push({ isHeader: false, lines: [line] }); + } else { + sections[sections.length - 1].lines.push(line); + } + } + + if (sections.filter(section => section.isHeader).length < 2) { + return undefined; + } + + const dropFirst = dropLeadingHeader && sections[0].isHeader; + const rows: string[] = []; + sections.forEach((section, index) => { + const lines = index === 0 && dropFirst ? section.lines.slice(1) : section.lines; + const markdown = lines.join('\n').trim(); + if (markdown) { + rows.push(markdown); + } + }); + + return rows.length ? rows : undefined; +} + +type ChatThinkingTitle = string | IMarkdownString; + +function getThinkingTitleValue(title: ChatThinkingTitle): string { + return typeof title === 'string' ? title : title.value; +} + +function thinkingTitleEqual(first: ChatThinkingTitle, second: ChatThinkingTitle): boolean { + if (typeof first === 'string' || typeof second === 'string') { + return first === second; + } + return markdownStringEqual(first, second); +} + +/** + * Metadata passed to {@link ChatThinkingContentPart.appendItem} to drive + * title / icon extraction. The `kind` discriminates which payload is + * available; the thinking part inspects it to compute a label like + * "Edited foo.ts" without rendering the actual content itself (the + * factory provides the DOM). + */ +export type ChatThinkingItemMetadata = + | IChatToolInvocation + | IChatToolInvocationSerialized + | IChatMarkdownContent + | IChatExternalEdit; + +interface ILazyToolItem { + kind: 'tool'; + lazy: Lazy<{ domNode: HTMLElement; disposable?: IDisposable }>; + toolInvocationId?: string; + toolInvocationOrMarkdown?: ChatThinkingItemMetadata; + originalParent?: HTMLElement; + isHook?: boolean; +} + +interface ILazyThinkingItem { + kind: 'thinking'; + textContainer: HTMLElement; + content: IChatThinkingPart; +} + +type ILazyItem = ILazyToolItem | ILazyThinkingItem; +const THINKING_SCROLL_MAX_HEIGHT = 200; + +const TITLE_CACHE_STORAGE_KEY = 'chat.thinkingTitleCache'; +const TITLE_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +const TITLE_CACHE_MAX_ENTRIES = 1000; + +const enum WorkingMessageCategory { + Thinking = 'thinking', + Terminal = 'terminal', + Tool = 'tool' +} + +export const defaultThinkingMessages = [ + localize('chat.thinking.thinking.1', 'Thinking'), + localize('chat.thinking.thinking.2', 'Reasoning'), + localize('chat.thinking.thinking.3', 'Considering'), + localize('chat.thinking.thinking.4', 'Analyzing'), + localize('chat.thinking.thinking.5', 'Evaluating'), + localize('chat.thinking.thinking.6', 'Working'), +]; + +const terminalMessages = [ + localize('chat.thinking.terminal.1', 'Executing'), + localize('chat.thinking.terminal.2', 'Running'), + localize('chat.thinking.terminal.3', 'Processing'), +]; + +const toolMessages = [ + localize('chat.thinking.tool.1', 'Processing'), + localize('chat.thinking.tool.2', 'Preparing'), + localize('chat.thinking.tool.3', 'Loading'), + localize('chat.thinking.tool.4', 'Analyzing'), + localize('chat.thinking.tool.5', 'Evaluating'), +]; + +/** Easter-egg loading messages, used ~1 in {@link FUN_WORKING_MESSAGE_RATE} picks. */ +const funWorkingMessages = [ + // Generic + localize('chat.working.fun.1', "Bribing the hamster"), + localize('chat.working.fun.2', "Reticulating splines"), + localize('chat.working.fun.3', "Untangling the spaghetti"), + localize('chat.working.fun.4', "Communing with the codebase"), + localize('chat.working.fun.5', "Letting it cook"), + localize('chat.working.fun.6', "Thanking all the fish"), + localize('chat.working.fun.7', "Stabilizing the wormhole"), + localize('chat.working.fun.8', "Baking the ideas"), + + // Code + localize('chat.working.fun.code.1', "Consulting the oracle"), + localize('chat.working.fun.code.2', "Shooting for the stars"), + localize('chat.working.fun.code.3', "Stirring the solution"), + + // Minecraft + localize('chat.working.fun.minecraft.1', "Mining diamonds"), + localize('chat.working.fun.minecraft.2', "Digging straight down"), + localize('chat.working.fun.minecraft.3', "Mining at night"), + + // Microsoft + localize('chat.working.fun.ms.1', "Summoning Clippy"), +]; + +const FUN_WORKING_MESSAGE_RATE = 50; + +type ThinkingPhrasesConfiguration = { mode?: 'replace' | 'append'; phrases?: string[] }; + +function getCustomThinkingPhrases(configurationService: IConfigurationService): { customPhrases: string[]; replaceDefaults: boolean } { + const config = configurationService.getValue(ChatConfiguration.ThinkingPhrases); + const customPhrases = Array.isArray(config?.phrases) + ? config.phrases + .filter((phrase): phrase is string => typeof phrase === 'string') + .map(phrase => phrase.trim()) + .filter(phrase => phrase.length > 0) + : []; + + return { + customPhrases, + replaceDefaults: config?.mode === 'replace' && customPhrases.length > 0, + }; +} + +/** Returns an easter-egg message ~1 in {@link FUN_WORKING_MESSAGE_RATE}, else `undefined`. */ +export function maybePickFunWorkingMessage(configurationService: IConfigurationService, random = Math.random): string | undefined { + if (getCustomThinkingPhrases(configurationService).replaceDefaults) { + return undefined; + } + + if (Math.floor(random() * FUN_WORKING_MESSAGE_RATE) === 0) { + return funWorkingMessages[Math.floor(random() * funWorkingMessages.length)]; + } + return undefined; +} + +/** + * Builds a phrase pool from defaults and user-configured custom phrases. + * In 'replace' mode, only custom phrases are used; in 'append' mode (default), + * custom phrases are added to the defaults. + */ +export function buildPhrasePool(defaults: string[], configurationService: IConfigurationService): string[] { + const { customPhrases, replaceDefaults } = getCustomThinkingPhrases(configurationService); + + if (customPhrases.length > 0) { + return replaceDefaults ? [...customPhrases] : [...defaults, ...customPhrases]; + } + return [...defaults]; +} + +export class ChatThinkingContentPart extends ChatThinkingStyleContentPart implements IChatContentPart { + + private static _codeBlockRendererSync(_languageId: string, text: string, _raw?: string): HTMLElement { + const codeElement = $('code'); + codeElement.textContent = text; + return codeElement; + } + + public readonly codeblocks: undefined; + public readonly codeblocksPartId: undefined; + + private readonly _onDidChangeHeight = this._register(new Emitter()); + private readonly _asyncRenderCallback = () => this._onDidChangeHeight.fire(); + + private id: string | undefined; + private content: IChatThinkingPart; + private currentThinkingValue: string; + private currentTitle: string; + private defaultTitle = localize('chat.thinking.header', 'Thinking'); + private readonly workingTitle = localize('chat.thinking.header.working', 'Working'); + private textContainer!: HTMLElement; + private readonly _markdownResult = this._register(new MutableDisposable()); + private summaryRowItems: HTMLElement[] = []; + private summaryRowResults: (IRenderedMarkdown | undefined)[] = []; + private summaryRowTexts: string[] = []; + private droppedSummaryHeader: string | undefined; + private readonly retiredSummaryRowResults: IRenderedMarkdown[] = []; + private wrapper!: HTMLElement; + private fixedScrollingMode: boolean = false; + private readonly thinkingDisplayMode: ThinkingDisplayMode; + private autoScrollEnabled: boolean = true; + private scrollableElement: DomScrollableElement | undefined; + private lastExtractedTitle: string | undefined; + private extractedTitles: string[] = []; + private toolInvocationCount: number = 0; + private appendedItemCount: number = 0; + private isActive: boolean = true; + private toolInvocations: (IChatToolInvocation | IChatToolInvocationSerialized)[] = []; + private allThinkingParts: IChatThinkingPart[] = []; + private hookCount: number = 0; + private singleItemInfo: { element: HTMLElement; thinkingWrapper: HTMLElement; originalParent: HTMLElement; originalNextSibling: Node | null; restoreToOriginalParent: boolean; toolInvocation?: IChatToolInvocation | IChatToolInvocationSerialized } | undefined; + private lazyItems: ILazyItem[] = []; + private hasExpandedOnce: boolean = false; + private workingSpinnerElement: HTMLElement | undefined; + private workingSpinnerLabel: HTMLElement | undefined; + private availableMessagesByCategory = new Map(); + private readonly toolWrappersByCallId = new Map(); + private readonly toolIconsByCallId = new Map(); + private readonly toolLabelsByCallId = new Map(); + private readonly toolDisposables = this._register(new DisposableMap()); + private readonly ownedToolParts = new Map(); + private pendingRemovals: { toolCallId: string; toolLabel: string }[] = []; + private pendingRemovalFlushDisposable: IDisposable | undefined; + private pendingScrollDisposable: IDisposable | undefined; + private wrapperResizeObserverDisposable: IDisposable | undefined; + private childResizeObserver: DisposableResizeObserver | undefined; + private isUpdatingDimensions: boolean = false; + private lastKnownContentHeight: number = 0; + private lastKnownScrollTop: number = 0; + private titleDetailContainer: HTMLElement | undefined; + private lastRenderedTitle: ChatThinkingTitle | undefined; + private collapsedTitleBeforeExpansion: ChatThinkingTitle | undefined; + private readonly _externalResourceWidget: ChatThinkingExternalResourceWidget; + private readonly _pendingExternalResources = new Map(); + private readonly _titleDetailRendered = this._register(new MutableDisposable()); + private readonly _pendingAppendRefresh = this._register(new MutableDisposable()); + private readonly diffDataByPartId = new Map(); + private _aggregatedDiff: IEditSessionDiffStats = { added: 0, removed: 0 }; + private readonly diffButtonStore = this._register(new DisposableStore()); + private diffButton: Button | undefined; + private containsReasoning: boolean; + private containsGroupedItems: boolean = false; + private reasoningDurationMs: number | undefined; + + get aggregatedDiff(): IEditSessionDiffStats { return this._aggregatedDiff; } + + private getRandomWorkingMessage(category: WorkingMessageCategory = WorkingMessageCategory.Tool): string { + const fun = maybePickFunWorkingMessage(this.configurationService); + if (fun) { + return fun; + } + + let pool = this.availableMessagesByCategory.get(category); + if (!pool || pool.length === 0) { + let defaults: string[]; + switch (category) { + case WorkingMessageCategory.Thinking: + defaults = defaultThinkingMessages; + break; + case WorkingMessageCategory.Terminal: + defaults = terminalMessages; + break; + case WorkingMessageCategory.Tool: + default: + defaults = toolMessages; + break; + } + + pool = buildPhrasePool(defaults, this.configurationService); + + this.availableMessagesByCategory.set(category, pool); + } + const index = Math.floor(Math.random() * pool.length); + return pool.splice(index, 1)[0]; + } + + constructor( + content: IChatThinkingPart, + context: IChatContentPartRenderContext, + private readonly chatContentMarkdownRenderer: IMarkdownRenderer, + private streamingCompleted: boolean, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @IConfigurationService private readonly configurationService: IConfigurationService, + @IChatMarkdownAnchorService private readonly chatMarkdownAnchorService: IChatMarkdownAnchorService, + @ILanguageModelsService private readonly languageModelsService: ILanguageModelsService, + @IHoverService hoverService: IHoverService, + @ITelemetryService telemetryService: ITelemetryService, + @IStorageService private readonly storageService: IStorageService, + @IContextKeyService contextKeyService: IContextKeyService, + @IEditorService private readonly editorService: IEditorService, + ) { + const initialText = extractTextFromPart(content); + const containsReasoning = initialText.trim().length > 0; + const extractedTitle = extractTitleFromThinkingContent(initialText) + ?? localize('chat.thinking.header.initial', 'Thinking'); + + super(extractedTitle, context, undefined, hoverService, configurationService, telemetryService); + + this.containsReasoning = containsReasoning; + this.reasoningDurationMs = content.reasoningDurationMs; + this.id = content.id; + this.content = content; + this.allThinkingParts.push(content); + const configuredMode = getEffectiveThinkingDisplayMode(this.configurationService, contextKeyService, context.readOnly); + this.thinkingDisplayMode = configuredMode; + + this.fixedScrollingMode = configuredMode === ThinkingDisplayMode.FixedScrolling; + + this.currentTitle = extractedTitle; + if (extractedTitle !== this.defaultTitle) { + this.lastExtractedTitle = extractedTitle; + this.extractedTitles.push(extractedTitle); + } + this.currentThinkingValue = initialText; + this.trackDroppedSummaryHeader(initialText); + + if (initialText.trim()) { + this.appendedItemCount++; + } + + // Alert screen reader users that thinking has started + if (this.configurationService.getValue(AccessibilityWorkbenchSettingId.VerboseChatProgressUpdates)) { + alert(localize('chat.thinking.started', 'Thinking')); + } + + if (configuredMode === ThinkingDisplayMode.Collapsed) { + this.setExpanded(false); + } else if (configuredMode === ThinkingDisplayMode.CollapsedPreview) { + // Start expanded if still in progress. + // streamingCompleted is true when look-ahead finds subsequent non-pinnable + // parts, meaning this thinking part won't receive more content. + this.setExpanded(!this.streamingCompleted && !this.element.isComplete); + } else { + this.setExpanded(false); + } + + const node = this.domNode; + if (this._hoverChevron) { + this._register(addDisposableListener(this._hoverChevron, EventType.CLICK, event => { + EventHelper.stop(event, true); + this.toggleExpanded(); + })); + } + + this._externalResourceWidget = this._register(this.instantiationService.createInstance(ChatThinkingExternalResourceWidget)); + this._register(this._externalResourceWidget.onDidChangeHeight(() => this._onDidChangeHeight.fire())); + node.appendChild(this._externalResourceWidget.domNode); + + if (!this.streamingCompleted && !this.element.isComplete) { + if (!this.fixedScrollingMode) { + node.classList.add('chat-thinking-active'); + } + } + + if (!this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this._collapseButton) { + this.setShimmerTitle(extractedTitle); + } + + if (this.fixedScrollingMode) { + node.classList.add('chat-thinking-fixed-mode'); + this.currentTitle = this.defaultTitle; + } + + this._register(toDisposable(() => { + for (const d of this.ownedToolParts.values()) { + d.dispose(); + } + this.ownedToolParts.clear(); + })); + + this._register(toDisposable(() => { + for (const result of this.summaryRowResults) { + result?.dispose(); + } + for (const result of this.retiredSummaryRowResults) { + result.dispose(); + } + })); + + this._register(autorun(r => { + const isExpanded = this._isExpanded.read(r); + // Materialize lazy items when first expanded + if (isExpanded && !this.hasExpandedOnce && this.lazyItems.length > 0) { + this.hasExpandedOnce = true; + // Flush pending removals so that completed hidden tools are removed from lazyItems before materialization + this.processPendingRemovals(); + for (const item of this.lazyItems) { + this.materializeLazyItem(item); + } + } + + // If expanded but content matches title and there's nothing else to show, revert immediately. + // Skip this check while still streaming — more content will arrive. + if (isExpanded && !this.shouldAllowExpansion() && (this.streamingCompleted || this.element.isComplete)) { + this.setExpanded(false); + return; + } + + this._externalResourceWidget.setCollapsed(!isExpanded); + + // Fire when expanded/collapsed + this._onDidChangeHeight.fire(); + })); + + const label = this.lastExtractedTitle ?? ''; + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(label); + } + + if (this._collapseButton) { + this._register(this._collapseButton.onDidClick(() => { + if (this.fixedScrollingMode) { + if (this.streamingCompleted) { + this.domNode.classList.add('chat-thinking-fixed-mode-animated'); + } + return; + } + + if (this.streamingCompleted) { + return; + } + + const expanded = this.isExpanded(); + if (expanded) { + // Just expanded: show plain 'Working' with no detail + this.collapsedTitleBeforeExpansion = this.lastRenderedTitle ?? this.lastExtractedTitle; + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } else { + // Restore the title that was visible before expansion. Tool state + // updates can become less descriptive while the section is open. + const collapsedTitle = this.collapsedTitleBeforeExpansion ?? this.lastRenderedTitle ?? this.lastExtractedTitle; + this.collapsedTitleBeforeExpansion = undefined; + if (collapsedTitle) { + this.setTitle(collapsedTitle); + } else { + this.setTitle(this.defaultTitle, true); + this.currentTitle = this.defaultTitle; + } + } + })); + } + } + + protected override shouldInitEarly(): boolean { + return this.fixedScrollingMode && !this.streamingCompleted; + } + + protected override shouldAnimateContent(): boolean { + return !this.fixedScrollingMode; + } + + protected override shouldPrepareContentAnimation(): boolean { + return !this.fixedScrollingMode; + } + + protected override contentDidInitialize(): void { + if (this.fixedScrollingMode && this.streamingCompleted && this.scrollableElement) { + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = '0px'; + scrollableDomNode.getBoundingClientRect(); + } + } + + protected override get collapsibleKind(): string { + return 'thinking'; + } + + protected override expansionDidChange(expanded: boolean): void { + if (this.fixedScrollingMode && this.streamingCompleted) { + if (expanded) { + this.syncDimensionsAndScheduleScroll(); + } else { + this.updateCompletedScrollAnimationState(false); + } + } + } + + // @TODO: @justschen Convert to template for each setting? + protected override getThinkingIcon(_active: boolean, expanded: boolean): ThemeIcon { + if (this.streamingCompleted || this.element.isComplete) { + return Codicon.checkCompact; + } + return !this.fixedScrollingMode && expanded ? Codicon.chevronDownCompact : Codicon.circleFilledCompact; + } + + protected override initContent(): HTMLElement { + this.wrapper = this.createThinkingBody(); + if (!this.streamingCompleted) { + this.wrapper.classList.add('chat-thinking-streaming'); + } + + // Only create textContainer here if there's no pending lazy thinking item. + // If there's a lazy thinking item, it will be rendered via materializeLazyItem + // with the latest streaming content. + const hasLazyThinkingItems = this.lazyItems.some(item => item.kind === 'thinking'); + if (this.currentThinkingValue && !hasLazyThinkingItems) { + this.textContainer = $('.chat-thinking-item.markdown-content'); + this.wrapper.appendChild(this.textContainer); + this.renderMarkdown(this.currentThinkingValue); + } + + if (!this.streamingCompleted && !this.element.isComplete) { + const spinner = this.createThinkingSpinnerRow(this.getRandomWorkingMessage(WorkingMessageCategory.Thinking)); + this.workingSpinnerElement = spinner.row; + this.workingSpinnerLabel = spinner.label; + this.wrapper.appendChild(spinner.row); + this.updateWorkingSpinnerVisibility(); + } + + // wrap content in scrollable element for fixed scrolling mode + if (this.fixedScrollingMode) { + this.scrollableElement = this._register(new DomScrollableElement(this.wrapper, { + vertical: ScrollbarVisibility.Auto, + horizontal: ScrollbarVisibility.Hidden, + handleMouseWheel: true, + alwaysConsumeMouseWheel: false + })); + this._register(this.scrollableElement.onScroll(e => this.handleScroll(e.scrollTop))); + + let pendingMutationRefresh: IDisposable | undefined; + const mutationObserver = new MutationObserver(() => { + if (pendingMutationRefresh) { + return; + } + pendingMutationRefresh = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { + pendingMutationRefresh = undefined; + if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + }); + mutationObserver.observe(this.wrapper, { childList: true, subtree: true }); + this._register({ + dispose: () => { + mutationObserver.disconnect(); + pendingMutationRefresh?.dispose(); + } + }); + + // Observe child elements for resizes (e.g. terminal output growing) + // so we can update scroll dimensions when the wrapper box is pinned at max-height. + this.childResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.child', () => { + if (this.streamingCompleted || !this.domNode.classList.contains('chat-used-context-collapsed')) { + return; + } + + this.syncDimensionsAndScheduleScroll(); + })); + if (this.textContainer) { + this._register(this.childResizeObserver.observe(this.textContainer)); + } + if (this.workingSpinnerElement) { + this._register(this.childResizeObserver.observe(this.workingSpinnerElement)); + } + + // Cache wrapper scrollHeight post-layout via ResizeObserver to avoid forced reflows. + const wrapperResizeObserver = this._register(new DisposableResizeObserver('ChatThinkingContentPart.wrapper', (entries) => { + if (entries[0]) { + this.lastKnownContentHeight = this.wrapper.scrollHeight; + if (this.streamingCompleted && this.isExpanded()) { + this.updateScrollDimensionsForCompletion(); + } else if (!this.streamingCompleted && this.domNode.classList.contains('chat-used-context-collapsed')) { + this.updateScrollDimensionsFromCache(); + } + } + })); + this.wrapperResizeObserverDisposable = this._register(wrapperResizeObserver.observe(this.wrapper)); + + // Once content exceeds max-height, the wrapper box size stops changing + // so ResizeObserver won't fire. Fall back to scrollHeight reads here. + this._register(this._onDidChangeHeight.event(() => { + if (!this.streamingCompleted && this.wrapperResizeObserverDisposable) { + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + return; + } + this.syncDimensionsAndScheduleScroll(); + })); + + this.syncDimensionsAndScheduleScroll(); + + this.updateDropdownClickability(); + return this.scrollableElement.getDomNode(); + } + + this.updateDropdownClickability(); + return this.wrapper; + } + + private handleScroll(scrollTop: number): void { + if (!this.scrollableElement || this.isUpdatingDimensions) { + return; + } + + this.lastKnownScrollTop = scrollTop; + const contentHeight = this.lastKnownContentHeight; + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = contentHeight - viewportHeight; + this.autoScrollEnabled = maxScrollTop <= 0 || scrollTop >= maxScrollTop - 10; + + this.updateFadeClasses(scrollTop, contentHeight, viewportHeight); + } + + private updateFadeClasses(scrollTop?: number, contentHeight?: number, viewportHeight?: number): void { + if (!this.fixedScrollingMode || this.streamingCompleted) { + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + return; + } + + const currentScrollTop = scrollTop ?? this.lastKnownScrollTop; + const currentContentHeight = contentHeight ?? this.lastKnownContentHeight; + const currentViewportHeight = viewportHeight ?? Math.min(currentContentHeight, THINKING_SCROLL_MAX_HEIGHT); + const maxScrollTop = currentContentHeight - currentViewportHeight; + + this.domNode.classList.toggle('chat-thinking-fade-top', currentScrollTop > 5); + this.domNode.classList.toggle('chat-thinking-fade-bottom', maxScrollTop > 0 && currentScrollTop < maxScrollTop - 5); + } + + // Fallback for non-ResizeObserver updates (onDidChangeHeight, initial setup). + private syncDimensionsAndScheduleScroll(): void { + if (this.pendingScrollDisposable) { + return; + } + this.pendingScrollDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { + this.pendingScrollDisposable = undefined; + if (this._store.isDisposed) { + return; + } + if (this.streamingCompleted) { + this.updateScrollDimensionsForCompletion(); + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + } + + /** + * Re-read scrollHeight from the DOM and update cached height if changed. + */ + private refreshContentHeight(): void { + if (!this.wrapper || !this.scrollableElement) { + return; + } + const newHeight = this.wrapper.scrollHeight; + if (newHeight && newHeight !== this.lastKnownContentHeight) { + this.lastKnownContentHeight = newHeight; + } + } + + private updateScrollDimensionsFromCache(): void { + if (!this.scrollableElement || this._store.isDisposed) { + return; + } + + const isCollapsed = this.domNode.classList.contains('chat-used-context-collapsed'); + if (!isCollapsed) { + return; + } + + const contentHeight = this.lastKnownContentHeight; + if (!contentHeight) { + return; + } + + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + this.isUpdatingDimensions = true; + try { + const viewportWidth = this.scrollableElement.getDomNode().clientWidth; + this.scrollableElement.setScrollDimensions({ + width: viewportWidth, + scrollWidth: viewportWidth, + height: viewportHeight, + scrollHeight: contentHeight + }); + + if (this.autoScrollEnabled) { + this.scrollToBottom(contentHeight); + } + } finally { + this.isUpdatingDimensions = false; + } + + this.updateFadeClasses(this.lastKnownScrollTop, this.lastKnownContentHeight); + this.updateDropdownClickability(contentHeight); + } + + private scrollToBottom(contentHeight: number): void { + if (!this.scrollableElement) { + return; + } + + const viewportHeight = Math.min(contentHeight, THINKING_SCROLL_MAX_HEIGHT); + + if (contentHeight > viewportHeight) { + const newScrollTop = contentHeight - viewportHeight; + this.lastKnownScrollTop = newScrollTop; + // Prevent reveal-on-scroll behavior from interfering with explicit bottom pinning. + this.scrollableElement.setRevealOnScroll(false); + this.scrollableElement.setScrollPosition({ scrollTop: newScrollTop }); + this.scrollableElement.setRevealOnScroll(true); + } + } + + /** + * updates scroll dimensions when streaming is complete. + */ + private updateScrollDimensionsForCompletion(): void { + if (!this.scrollableElement || !this.fixedScrollingMode) { + return; + } + + const contentHeight = this.wrapper.scrollHeight; + this.lastKnownContentHeight = contentHeight; + + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = `${contentHeight}px`; + const viewportWidth = scrollableDomNode.clientWidth; + this.scrollableElement.setScrollDimensions({ + width: viewportWidth, + scrollWidth: viewportWidth, + height: contentHeight, + scrollHeight: contentHeight + }); + this.lastKnownScrollTop = 0; + this.scrollableElement.setRevealOnScroll(false); + this.scrollableElement.setScrollPosition({ scrollTop: 0 }); + this.scrollableElement.setRevealOnScroll(true); + this.updateCompletedScrollAnimationState(this.isExpanded()); + } + + private updateCompletedScrollAnimationState(expanded: boolean): void { + if (!this.scrollableElement) { + return; + } + const scrollableDomNode = this.scrollableElement.getDomNode(); + scrollableDomNode.style.maxHeight = expanded ? `${this.lastKnownContentHeight}px` : '0px'; + scrollableDomNode.inert = !expanded; + } + + private renderMarkdown(content: string, reuseExisting?: boolean): void { + // Guard against rendering after disposal to avoid leaking disposables + if (this._store.isDisposed) { + return; + } + + // A later thinking part reassigns textContainer; retire stale row tracking + // so the predecessor's rendered rows stay frozen while this part renders. + if (this.summaryRowItems.length && this.summaryRowItems[0] !== this.textContainer) { + this.retireSummaryRows(); + } + + const cleanedContent = content.trim(); + if (!cleanedContent) { + this._markdownResult.clear(); + this.clearSummaryRows(); + if (this.textContainer) { + clearNode(this.textContainer); + } + return; + } + + // Multi-header reasoning summaries render each header section as its own + // row so the dropdown reads as a list. Sibling rows need an attached container so their + // insertion isn't a no-op, so a detached (lazy) container falls through to + // single-block rendering until it is materialized. A block drops its leading + // header only when that header is the tracked title owner, so a grouped block + // never drops a header that isn't surfaced as the title. + const dropLeadingHeader = this.droppedSummaryHeader !== undefined && extractTitleFromThinkingContent(cleanedContent) === this.droppedSummaryHeader; + const summaryRows = splitReasoningSummaryRows(cleanedContent, dropLeadingHeader); + if (summaryRows && this.textContainer?.parentNode) { + this.renderSummaryRows(summaryRows); + return; + } + this.clearSummaryRows(); + + // If the entire content is bolded, strip the bold markers for rendering + const contentToRender = stripStandaloneBold(cleanedContent); + + const target = reuseExisting ? this._markdownResult.value?.element : undefined; + + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(contentToRender), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, target); + this._markdownResult.value = rendered; + if (!target) { + if (this.textContainer) { + clearNode(this.textContainer); + this.textContainer.appendChild(createThinkingIcon(Codicon.circleFilled)); + this.textContainer.appendChild(rendered.element); + } + } + } + + /** Renders one summary row, reusing the row's element while its text only grows. */ + private renderSummaryRow(container: HTMLElement, index: number, markdown: string): void { + const previous = this.summaryRowResults[index]; + const reuse = !!previous && markdown.startsWith(this.summaryRowTexts[index] ?? ''); + // A standalone header renders as plain text, not bold. + const rendered = this.chatContentMarkdownRenderer.render(new MarkdownString(stripStandaloneBold(markdown)), { + fillInIncompleteTokens: true, + asyncRenderCallback: this._asyncRenderCallback, + codeBlockRendererSync: ChatThinkingContentPart._codeBlockRendererSync, + }, reuse ? previous?.element : undefined); + if (!reuse) { + clearNode(container); + container.appendChild(createThinkingIcon(Codicon.circleFilled)); + container.appendChild(rendered.element); + } + previous?.dispose(); + this.summaryRowResults[index] = rendered; + this.summaryRowTexts[index] = markdown; + } + + private renderSummaryRows(rows: string[]): void { + // Rows own the DOM in this mode; release the single-block renderer. + this._markdownResult.clear(); + + for (let i = 0; i < rows.length; i++) { + let container = this.summaryRowItems[i]; + if (!container) { + container = i === 0 ? this.textContainer : $('.chat-thinking-item.markdown-content'); + this.summaryRowItems[i] = container; + this.summaryRowTexts[i] = ''; + if (i === 0) { + clearNode(container); + } else { + this.summaryRowItems[i - 1].after(container); + } + } + if (this.summaryRowTexts[i] !== rows[i]) { + this.renderSummaryRow(container, i, rows[i]); + } + } + + // Streaming only appends, but guard against a shrinking row set on re-render. + for (let i = this.summaryRowItems.length - 1; i >= rows.length; i--) { + this.summaryRowResults[i]?.dispose(); + if (this.summaryRowItems[i] !== this.textContainer) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems.length = rows.length; + this.summaryRowResults.length = rows.length; + this.summaryRowTexts.length = rows.length; + } + + /** Removes the extra summary rows and resets tracking, keeping the text container. */ + private clearSummaryRows(): void { + if (!this.summaryRowItems.length) { + return; + } + for (let i = 0; i < this.summaryRowItems.length; i++) { + this.summaryRowResults[i]?.dispose(); + if (i !== 0) { + this.summaryRowItems[i].remove(); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** Keeps a prior part's rendered rows in the DOM; defers disposal to teardown. */ + private retireSummaryRows(): void { + for (const result of this.summaryRowResults) { + if (result) { + this.retiredSummaryRowResults.push(result); + } + } + this.summaryRowItems = []; + this.summaryRowResults = []; + this.summaryRowTexts = []; + } + + /** + * Records the leading header the primary summary block drops, derived from content + * so it is available at finalize even when the rows never lazily rendered (the + * collapsed-through-completion flow). First-writer wins: the first grouped block + * that is a multi-header summary owns the title, and only that header is dropped. + */ + private trackDroppedSummaryHeader(value: string): void { + if (this.droppedSummaryHeader) { + return; + } + const trimmed = value.trim(); + if (splitReasoningSummaryRows(trimmed, true)) { + this.droppedSummaryHeader = extractTitleFromThinkingContent(trimmed); + if (this.fixedScrollingMode && this.droppedSummaryHeader && this.currentTitle !== this.droppedSummaryHeader) { + this.setTitle(this.droppedSummaryHeader); + } + } + } + + private setFinalizedTitle(title: string): void { + if (!this._collapseButton) { + return; + } + + const displayTitle = this.getFinalizedDisplayTitle(title); + this.clearTitleDetail(); + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + this.forgetShimmerTitle(); + + const firstSpaceIndex = displayTitle.indexOf(' '); + if (firstSpaceIndex === -1) { + // Single word title, no need to split + labelElement.textContent = displayTitle; + } else { + const verb = displayTitle.substring(0, firstSpaceIndex); + const rest = displayTitle.substring(firstSpaceIndex); + + const verbSpan = $('span'); + verbSpan.textContent = verb; + labelElement.appendChild(verbSpan); + + const restSpan = $('span.chat-thinking-title-detail-text'); + restSpan.textContent = rest; + labelElement.appendChild(restSpan); + } + + // Show aggregated diff stats from edit pills (only when there are actual changes) + if (this.diffDataByPartId.size > 0) { + const { added, removed } = this._aggregatedDiff; + if (added > 0 || removed > 0) { + this.renderDiffButton(added, removed); + + const insertionsFragment = added === 1 ? localize('chat.thinking.insertions.one', "1 insertion") : localize('chat.thinking.insertions', "{0} insertions", added); + const deletionsFragment = removed === 1 ? localize('chat.thinking.deletions.one', "1 deletion") : localize('chat.thinking.deletions', "{0} deletions", removed); + this.setAriaLabel(localize('chat.thinking.titleWithDiff', "{0}, {1}, {2}", displayTitle, insertionsFragment, deletionsFragment)); + } else { + this.clearDiffButton(); + this.setAriaLabel(displayTitle); + } + } else { + this.clearDiffButton(); + this.setAriaLabel(displayTitle); + } + } + + private renderDiffButton(added: number, removed: number): void { + const resources = this.getAggregatedDiffResources(); + if (resources.length === 0) { + this.clearDiffButton(); + return; + } + + if (!this.diffButton) { + const collapseButton = this._collapseButton; + const container = collapseButton?.element.parentElement; + if (!container) { + return; + } + + collapseButton.element.classList.add('chat-thinking-title-with-diff'); + const button = this.diffButtonStore.add(new Button(container, {})); + button.element.classList.add('chat-thinking-title-diff'); + this.diffButtonStore.add(button.onDidClick(event => { + EventHelper.stop(event, true); + this.openDiffs(); + })); + this.diffButtonStore.add(this.hoverService.setupDelayedHover(button.element, { + content: localize('chat.thinking.viewChanges', "View File Changes"), + style: HoverStyle.Pointer, + })); + this.diffButton = button; + + if (this._hoverChevron) { + container.appendChild(this._hoverChevron); + } + } + + this.diffButton.element.replaceChildren( + $('span.label-added', {}, `+${added}`), + $('span.label-removed', {}, `-${removed}`), + ); + this.diffButton.setAriaLabel(localize( + 'chat.thinking.viewChangesAccessible', + 'View file changes, {0} lines added, {1} lines deleted', + added, + removed, + )); + } + + private clearDiffButton(): void { + this.diffButtonStore.clear(); + this.diffButton = undefined; + const collapseButton = this._collapseButton; + collapseButton?.element.classList.remove('chat-thinking-title-with-diff'); + const container = collapseButton?.element.parentElement; + if (collapseButton && container && this._hoverChevron) { + if (this.titleDetailContainer?.parentElement === container) { + container.appendChild(this._hoverChevron); + } else { + collapseButton.element.appendChild(this._hoverChevron); + } + } + } + + private getAggregatedDiffResources(): IChatContentPartDiffResource[] { + const result = new Map(); + + for (const data of this.diffDataByPartId.values()) { + for (const resource of data.resources) { + const key = getComparisonKey(resource.resource); + const existing = result.get(key); + if (existing) { + existing.resource = resource.resource; + existing.modifiedURI = resource.modifiedURI; + } else { + result.set(key, { ...resource }); + } + } + } + + return [...result.values()].filter(resource => resource.originalURI !== undefined || resource.modifiedURI !== undefined); + } + + private openDiffs(): void { + const resources = this.getAggregatedDiffResources(); + if (resources.length === 0) { + return; + } + + const source = URI.parse(`multi-diff-editor:${Date.now().toString()}-${Math.random().toString(36).slice(2)}`); + this.editorService.openEditor({ + multiDiffSource: source, + label: localize('chat.thinking.changes.title', "Section File Changes"), + resources: resources.map(resource => ({ + original: { resource: resource.originalURI }, + modified: { resource: resource.modifiedURI }, + goToFileResource: resource.resource, + })), + }); + } + + private getFinalizedDisplayTitle(title: string): string { + if (this.thinkingDisplayMode !== ThinkingDisplayMode.Collapsed || !this.containsReasoning || this.containsGroupedItems || !this.reasoningDurationMs) { + return title; + } + + const seconds = Math.ceil(this.reasoningDurationMs / 1000); + const duration = localize('chat.thinking.duration.seconds', "{0}s", seconds); + return localize('chat.thinking.titleWithDuration', "{0} - {1}", title, duration); + } + + public hasReasoningContent(): boolean { + return this.containsReasoning; + } + + public hasGroupedItems(): boolean { + return this.containsGroupedItems; + } + + private recordReasoningContent(content: string): void { + if (!content.trim()) { + return; + } + this.containsReasoning = true; + } + + private setDropdownClickable(clickable: boolean): void { + if (this._collapseButton) { + this._collapseButton.element.style.pointerEvents = clickable ? 'auto' : 'none'; + } + + if (!clickable && this.streamingCompleted) { + this.setFinalizedTitle(this.lastExtractedTitle ?? this.currentTitle); + } + } + + private shouldAllowExpansion(): boolean { + // Multiple tool invocations or lazy items mean there's content to show + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0) { + return true; + } + + // Count meaningful children in the wrapper (exclude the working spinner) + if (this.wrapper) { + const meaningfulChildren = Array.from(this.wrapper.children).filter(child => child !== this.workingSpinnerElement).length; + if (meaningfulChildren > 1) { + return true; + } + } + + const contentWithoutTitle = this.currentThinkingValue.trim(); + const titleToCompare = this.lastExtractedTitle ?? this.currentTitle; + + const stripMarkdown = (text: string) => { + return text + .replace(/\*\*(.+?)\*\*/g, '$1').replace(/\*(.+?)\*/g, '$1').replace(/`(.+?)`/g, '$1').trim(); + }; + + const strippedContent = stripMarkdown(contentWithoutTitle); + // If content is empty or matches the title exactly, nothing to expand + return !(!strippedContent || strippedContent === titleToCompare); + } + + private updateDropdownClickability(knownContentHeight?: number): void { + let allowExpansion = this.shouldAllowExpansion(); + + // don't allow feedback on fixed scrolling before reaching max height. + if (allowExpansion && this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete && this.wrapper) { + // Use only the cached height — never read scrollHeight here to avoid forced reflows. + // If the cache is empty, conservatively disallow expansion; the ResizeObserver + // will populate lastKnownContentHeight and trigger another call once layout settles. + const contentHeight = knownContentHeight ?? this.lastKnownContentHeight; + if (!contentHeight || contentHeight <= THINKING_SCROLL_MAX_HEIGHT) { + allowExpansion = false; + } + } + + if (!allowExpansion && this.isExpanded() && (this.streamingCompleted || this.element.isComplete)) { + this.setExpanded(false); + } + this.setDropdownClickable(allowExpansion); + } + + private appendToWrapper(element: HTMLElement): void { + if (!this.wrapper) { + return; + } + if (this.workingSpinnerElement && this.workingSpinnerElement.parentNode === this.wrapper) { + this.wrapper.insertBefore(element, this.workingSpinnerElement); + } else { + this.wrapper.appendChild(element); + } + } + + private updateWorkingSpinnerVisibility(reader?: IReader): void { + if (!this.wrapper || !this.workingSpinnerElement) { + return; + } + + const hasRunningTerminalTool = this.toolInvocations.some(toolInvocation => { + const terminalData = toolInvocation.toolSpecificData as IChatTerminalToolInvocationData | undefined; + if (terminalData?.kind !== 'terminal' || terminalData.terminalCommandState?.exitCode !== undefined) { + return false; + } + + return !IChatToolInvocation.isComplete(toolInvocation, reader); + }); + + const isAttached = this.workingSpinnerElement.parentNode === this.wrapper; + if (hasRunningTerminalTool && isAttached) { + this.workingSpinnerElement.remove(); + this._onDidChangeHeight.fire(); + } else if (!hasRunningTerminalTool && !isAttached && !this.streamingCompleted && !this.element.isComplete) { + this.wrapper.appendChild(this.workingSpinnerElement); + this._onDidChangeHeight.fire(); + } + } + + public resetId(): void { + this.id = undefined; + } + + public collapseContent(): void { + this.setExpanded(false); + } + + public updateThinking(content: IChatThinkingPart): void { + // If disposed, ignore late updates coming from renderer diffing + if (this._store.isDisposed) { + return; + } + this.content = content; + this.reasoningDurationMs = content.reasoningDurationMs; + + // Update any pending lazy thinking item with matching ID so that + // when materialized, it will have the latest streaming content + for (const lazyItem of this.lazyItems) { + if (lazyItem.kind === 'thinking' && lazyItem.content.id === content.id) { + lazyItem.content = content; + break; + } + } + + const raw = extractTextFromPart(content); + this.recordReasoningContent(raw); + const next = raw; + if (next === this.currentThinkingValue) { + return; + } + const previousValue = this.currentThinkingValue; + const reuseExisting = !!(this._markdownResult.value && next.startsWith(previousValue) && next.length > previousValue.length); + this.currentThinkingValue = next; + this.trackDroppedSummaryHeader(next); + this.renderMarkdown(next, reuseExisting); + + if (this.fixedScrollingMode && this.scrollableElement) { + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + } + + const extractedTitle = extractTitleFromThinkingContent(raw); + if (extractedTitle && extractedTitle !== this.currentTitle) { + if (!this.extractedTitles.includes(extractedTitle)) { + this.extractedTitles.push(extractedTitle); + } + this.lastExtractedTitle = extractedTitle; + } + + if (!extractedTitle || extractedTitle === this.currentTitle) { + return; + } + + const label = this.lastExtractedTitle ?? ''; + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(label); + } + + this.updateDropdownClickability(); + } + + public getIsActive(): boolean { + return this.isActive; + } + + /** + * Returns true when this thinking part has no meaningful content to display: + * no tool invocations, no lazy items, no hooks, and no thinking text. + * This happens when a tool is removed from thinking (e.g. due to confirmation) + * and the thinking part was only created to hold that tool. + */ + public isEffectivelyEmpty(): boolean { + this.processPendingRemovals(); + if (this.toolInvocationCount > 0 || this.lazyItems.length > 0 || this.hookCount > 0) { + return false; + } + if (this.currentThinkingValue.trim().length > 0) { + return false; + } + return true; + } + + public markAsInactive(): void { + this.isActive = false; + this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + this.processPendingRemovals(); + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + + // Clear the attached-to-thinking flag on all tool invocations + for (const toolInvocation of this.toolInvocations) { + toolInvocation.isAttachedToThinking = false; + } + } + + public finalizeTitleIfDefault(): void { + this.processPendingRemovals(); + + // With lazy rendering, wrapper may not be created yet if content hasn't been expanded + if (this.wrapper) { + this.wrapper.classList.remove('chat-thinking-streaming'); + } + this.domNode.classList.remove('chat-thinking-active'); + this.domNode.classList.remove('chat-thinking-fade-top', 'chat-thinking-fade-bottom'); + this.streamingCompleted = true; + this.setContentAnimationEnabled(!this.fixedScrollingMode); + + // Now that streaming is complete, render any aggregated images that were + // deferred while scrolling was pinned in fixed scrolling mode. + this.flushPendingExternalResources(); + + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + + if (this._collapseButton) { + this._collapseButton.icon = Codicon.checkCompact; + } + + // Update scroll dimensions now that streaming is complete + // This removes unnecessary scrollbar when content fits + this.updateScrollDimensionsForCompletion(); + + this.updateDropdownClickability(); + + // A leading summary header removed from the rows must remain the title, even when a restored generated title exists. + if (this.droppedSummaryHeader) { + this.currentTitle = this.droppedSummaryHeader; + this.content.generatedTitle = this.droppedSummaryHeader; + this.setGeneratedTitleOnAllParts(this.droppedSummaryHeader); + this.setFinalizedTitle(this.droppedSummaryHeader); + return; + } + + if (this.content.generatedTitle) { + this.currentTitle = this.content.generatedTitle; + this.setGeneratedTitleOnAllParts(this.content.generatedTitle); + this.setFinalizedTitle(this.content.generatedTitle); + return; + } + + // Reuse any existing generated title from tool invocations or thinking parts. + const existingTitle = this.toolInvocations.find(t => t.generatedTitle)?.generatedTitle + ?? this.allThinkingParts.find(t => t.generatedTitle)?.generatedTitle; + if (existingTitle) { + this.currentTitle = existingTitle; + this.content.generatedTitle = existingTitle; + this.setGeneratedTitleOnAllParts(existingTitle); + this.setFinalizedTitle(existingTitle); + return; + } + + // Only check the persisted cache when re-rendering (tool invocations are + // serialized), not during live streaming. Reasoning-only blocks (no tools) + // are keyed off the stable thinking part id so their generated headers are + // also restored on reload (non-local sessions only). + const allToolsSerialized = this.toolInvocations.every(t => t.kind === 'toolInvocationSerialized'); + if (allToolsSerialized && !LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const cacheId = this.getTitleCacheId(); + if (cacheId) { + const cachedTitle = this.getCachedTitle(cacheId); + if (cachedTitle) { + this.currentTitle = cachedTitle; + this.content.generatedTitle = cachedTitle; + this.setGeneratedTitleOnAllParts(cachedTitle); + this.setFinalizedTitle(cachedTitle); + return; + } + } + } + + // case where we only have one item (tool or edit) in the thinking container and no thinking parts, we want to move it back to its original position + if (this.toolInvocationCount === 1 && this.hookCount === 0 && this.currentThinkingValue.trim() === '') { + // If singleItemInfo wasn't set (item was lazy/deferred), materialize it now + if (!this.singleItemInfo) { + const lazyItem = this.lazyItems.find(item => item.kind === 'tool' && item.originalParent); + if (lazyItem && lazyItem.kind === 'tool') { + const toolInvocation = lazyItem.toolInvocationOrMarkdown && (lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || lazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? lazyItem.toolInvocationOrMarkdown : undefined; + const result = lazyItem.lazy.value; + this.appendItemToDOM(result.domNode, lazyItem.toolInvocationId, lazyItem.toolInvocationOrMarkdown, lazyItem.originalParent); + if (result.disposable) { + const toolCallId = toolInvocation?.toolCallId; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } + } + if (this.singleItemInfo && this.restoreSingleItemToOriginalPosition()) { + return; + } + } + + // if exactly one actual extracted title and no tool invocations, use that as the final title. + if (this.extractedTitles.length === 1 && this.toolInvocationCount === 0) { + const title = this.extractedTitles[0]; + this.currentTitle = title; + this.content.generatedTitle = title; + this.setGeneratedTitleOnAllParts(title); + this.setFinalizedTitle(title); + return; + } + + const generateTitles = this.configurationService.getValue(ChatConfiguration.ThinkingGenerateTitles) ?? true; + if (!generateTitles) { + this.setFallbackTitle(); + return; + } + + this.generateTitleViaLLM(); + } + + private setGeneratedTitleOnAllParts(title: string): void { + for (const toolInvocation of this.toolInvocations) { + toolInvocation.generatedTitle = title; + } + for (const thinkingPart of this.allThinkingParts) { + thinkingPart.generatedTitle = title; + } + } + + private loadTitleCache(): Record { + return this.storageService.getObject>(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE) ?? {}; + } + + private saveTitleCache(cache: Record): void { + if (Object.keys(cache).length === 0) { + this.storageService.remove(TITLE_CACHE_STORAGE_KEY, StorageScope.PROFILE); + } else { + this.storageService.store(TITLE_CACHE_STORAGE_KEY, JSON.stringify(cache), StorageScope.PROFILE, StorageTarget.MACHINE); + } + } + + private getTitleCacheKey(id: string): string { + return `${chatSessionResourceToId(this.element.sessionResource)}:${id}`; + } + + /** + * Stable id used to persist/restore the generated title. Tool-based blocks + * key off the last tool call id; reasoning-only blocks fall back to the + * thinking part id so their headers also survive a session reload. + */ + private getTitleCacheId(): string | undefined { + const lastTool = this.toolInvocations[this.toolInvocations.length - 1]; + if (lastTool) { + return lastTool.toolCallId; + } + return this.allThinkingParts.find(t => t.id)?.id ?? this.content.id; + } + + private getCachedTitle(id: string): string | undefined { + const entry = this.loadTitleCache()[this.getTitleCacheKey(id)]; + if (!entry || (Date.now() - entry.storedAt) > TITLE_CACHE_TTL_MS) { + return undefined; + } + return entry.title; + } + + private setCachedTitle(id: string, title: string): void { + const cache = this.loadTitleCache(); + const now = Date.now(); + + // Evict expired entries on write + for (const key of Object.keys(cache)) { + if ((now - cache[key].storedAt) > TITLE_CACHE_TTL_MS) { + delete cache[key]; + } + } + + cache[this.getTitleCacheKey(id)] = { title, storedAt: now }; + + // Cap size by dropping oldest entries + const keys = Object.keys(cache); + if (keys.length > TITLE_CACHE_MAX_ENTRIES) { + const sorted = keys.sort((a, b) => cache[a].storedAt - cache[b].storedAt); + for (let i = 0; i < sorted.length - TITLE_CACHE_MAX_ENTRIES; i++) { + delete cache[sorted[i]]; + } + } + + this.saveTitleCache(cache); + } + + private async generateTitleViaLLM(): Promise { + const cts = new CancellationTokenSource(); + const timeout = setTimeout(() => cts.cancel(), 5000); + + try { + const models = await this.languageModelsService.selectLanguageModels({ vendor: 'copilot', id: 'copilot-utility-small' }); + if (!models.length) { + this.setFallbackTitle(); + return; + } + + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + + let context: string; + if (this.extractedTitles.length > 0) { + context = this.extractedTitles.join(', '); + } else { + context = this.currentThinkingValue.substring(0, 1000); + } + + const prompt = `Summarize the following content in a SINGLE sentence (under 10 words) using past tense. Follow these rules strictly: + + OUTPUT FORMAT: + - MUST be a single sentence + - MUST be under 10 words + - The FIRST word MUST be a past tense verb (e.g. "Updated", "Reviewed", "Created", "Searched", "Analyzed") + - No quotes, no trailing punctuation + + GENERAL: + - The content may include tool invocations (file edits, reads, searches, terminal commands), reasoning headers, or raw thinking text + - For reasoning headers or thinking text (no tool calls), summarize WHAT was considered/analyzed, NOT that thinking occurred + - For thinking-only summaries, use phrases like: "Considered...", "Planned...", "Analyzed...", "Reviewed..." + + TOOL NAME FILTERING: + - NEVER include tool names like "Replace String in File", "Multi Replace String in File", "Create File", "Read File", etc. in the output + - If an action says "Edited X and used Replace String in File", output ONLY the action on X + - Tool names describe HOW something was done, not WHAT was done - always omit them + + VOCABULARY - Use varied synonyms for natural-sounding summaries: + - For edits: "Updated", "Modified", "Changed", "Refactored", "Fixed", "Adjusted" + - For reads: "Reviewed", "Examined", "Checked", "Inspected", "Analyzed", "Explored" + - For creates: "Created", "Added", "Generated" + - For searches: "Searched for", "Looked up", "Investigated" + - For terminal: "Ran command", "Executed" + - For reasoning/thinking: "Considered", "Planned", "Analyzed", "Reviewed", "Evaluated" + - Choose the synonym that best fits the context + +${this.hookCount > 0 ? `BLOCKED/DENIED CONTENT (hooks detected): + - Only mention "blocked" if the content explicitly includes hook results that blocked or warned about a tool (e.g. "Blocked terminal" or "Warning for read_file") + - If blocked items are present alongside normal tool calls, briefly note the block but do NOT let it dominate the summary: e.g. "Updated file.ts, blocked terminal" + + ` : `IMPORTANT: Do NOT use words like "blocked", "denied", or "tried" in the summary - there are no hooks or blocked items in this content. Just summarize normally. + + `}RULES FOR TOOL CALLS: + 1. If the SAME file was both edited AND read: Use a combined phrase like "Reviewed and updated " + 2. If exactly ONE file was edited: Start with an edit synonym + "" (include actual filename) + 3. If exactly ONE file was read: Start with a read synonym + "" (include actual filename) + 4. If MULTIPLE files were edited: Start with an edit synonym + "X files" + 5. If MULTIPLE files were read: Start with a read synonym + "X files" + 6. If BOTH edits AND reads occurred on DIFFERENT files: Combine them naturally + 7. For searches: Say "searched for " or "looked up " with the actual search term, NOT "searched for files" + 8. After the file info, you may add a brief summary of other actions if space permits + 9. NEVER say "1 file" - always use the actual filename when there's only one file + + RULES FOR REASONING HEADERS (no tool calls): + 1. If the input contains reasoning/analysis headers without actual tool invocations, summarize the main topic and what was considered + 2. Use past tense verbs that indicate thinking, not doing: "Considered", "Planned", "Analyzed", "Evaluated" + 3. Focus on WHAT was being thought about, not that thinking occurred + + RULES FOR RAW THINKING TEXT: + 1. Extract the main topic or question being considered from the text + 2. Identify any specific files, functions, or concepts mentioned + 3. Summarize as "Analyzed " or "Considered " + 4. If discussing code structure: "Reviewed " + 5. If discussing a problem: "Analyzed " + 6. If discussing implementation: "Planned " + + EXAMPLES WITH TOOLS: + - "Read HomePage.tsx, Edited HomePage.tsx" → "Reviewed and updated HomePage.tsx" + - "Edited HomePage.tsx" → "Updated HomePage.tsx" + - "Edited config.css and used Replace String in File" → "Modified config.css" + - "Edited App.tsx, used Multi Replace String in File" → "Refactored App.tsx" + - "Read config.json, Read package.json" → "Reviewed 2 files" + - "Edited App.tsx, Read utils.ts" → "Updated App.tsx and checked utils.ts" + - "Edited App.tsx, Read utils.ts, Read types.ts" → "Updated App.tsx and reviewed 2 files" + - "Edited index.ts, Edited styles.css, Ran terminal command" → "Modified 2 files and ran command" + - "Read README.md, Searched for AuthService" → "Checked README.md and searched for AuthService" + - "Searched for login, Searched for authentication" → "Searched for login and authentication" + - "Edited api.ts, Edited models.ts, Read schema.json" → "Updated 2 files and reviewed schema.json" + - "Edited Button.tsx, Edited Button.css, Edited index.ts" → "Modified 3 files" + - "Searched codebase for error handling" → "Looked up error handling" + +${this.hookCount > 0 ? `EXAMPLES WITH BLOCKED CONTENT (from hooks): + - "Blocked terminal, Edited config.ts" → "Edited config.ts, terminal was blocked" + - "Blocked terminal, Blocked read_file" → "Two tools were blocked by hooks" + - "Warning for read_file, Edited utils.ts" → "Edited utils.ts with a hook warning" + + ` : ''}EXAMPLES WITH REASONING HEADERS (no tools): + - "Analyzing component architecture" → "Considered component architecture" + - "Planning refactor strategy" → "Planned refactor strategy" + - "Reviewing error handling approach, Considering edge cases" → "Analyzed error handling approach" + - "Understanding the codebase structure" → "Reviewed codebase structure" + - "Thinking about implementation options" → "Considered implementation options" + + EXAMPLES WITH RAW THINKING TEXT: + - "I need to understand how the authentication flow works in this app..." → "Analyzed authentication flow" + - "Let me think about how to refactor this component to be more maintainable..." → "Planned component refactoring" + - "The error seems to be coming from the database connection..." → "Investigated database connection issue" + - "Looking at the UserService class, I see it handles..." → "Reviewed UserService implementation" + + Content: ${context}`; + + const response = await this.languageModelsService.sendChatRequest( + models[0], + undefined, + [{ role: ChatMessageRole.User, content: [{ type: 'text', value: prompt }] }], + {}, + cts.token + ); + + let generatedTitle = ''; + for await (const part of response.stream) { + if (cts.token.isCancellationRequested) { + break; + } + if (Array.isArray(part)) { + for (const p of part) { + if (p.type === 'text') { + generatedTitle += p.value; + } + } + } else if (part.type === 'text') { + generatedTitle += part.value; + } + } + + if (cts.token.isCancellationRequested) { + this.setFallbackTitle(); + return; + } + + await response.result; + generatedTitle = generatedTitle.trim(); + + if (generatedTitle.includes('can\'t assist with that')) { + this.setFallbackTitle(); + return; + } + + if (generatedTitle && !this._store.isDisposed) { + this.currentTitle = generatedTitle; + this.setFinalizedTitle(generatedTitle); + this.content.generatedTitle = generatedTitle; + this.setGeneratedTitleOnAllParts(generatedTitle); + + // Persist to storage for non-local sessions only + if (!LocalChatSessionUri.isLocalSession(this.element.sessionResource)) { + const cacheId = this.getTitleCacheId(); + if (cacheId) { + this.setCachedTitle(cacheId, generatedTitle); + } + } + + return; + } + } catch (error) { + // fall through to default title + } finally { + clearTimeout(timeout); + cts.dispose(); + } + + this.setFallbackTitle(); + } + + private restoreSingleItemToOriginalPosition(): boolean { + if (!this.singleItemInfo) { + return false; + } + + const { element, thinkingWrapper, originalParent, originalNextSibling, restoreToOriginalParent, toolInvocation } = this.singleItemInfo; + + const hasOtherThinkingItems = this.wrapper && Array.from(this.wrapper.children).some(child => + child !== thinkingWrapper && child !== this.workingSpinnerElement + ); + if (hasOtherThinkingItems) { + this.singleItemInfo = undefined; + return false; + } + + const precedingToolInvocationPart = isHTMLElement(originalNextSibling) && originalNextSibling.parentElement === originalParent + ? originalNextSibling.previousElementSibling + : originalParent.lastElementChild; + if (restoreToOriginalParent) { + if (originalNextSibling && originalNextSibling.parentNode === originalParent) { + originalParent.insertBefore(element, originalNextSibling); + } else { + originalParent.appendChild(element); + } + } else if (precedingToolInvocationPart?.classList.contains('chat-tool-invocation-part')) { + precedingToolInvocationPart.appendChild(element); + } else if (originalNextSibling && originalNextSibling.parentNode === originalParent) { + originalParent.insertBefore(element, originalNextSibling); + } else { + originalParent.appendChild(element); + } + thinkingWrapper.remove(); + + if (toolInvocation) { + this.toolWrappersByCallId.delete(toolInvocation.toolCallId); + this.toolIconsByCallId.delete(toolInvocation.toolCallId); + toolInvocation.isAttachedToThinking = false; + } + + hide(this.domNode); + this.singleItemInfo = undefined; + return true; + } + + private updateAggregatedDiff(): void { + let totalAdded = 0; + let totalRemoved = 0; + for (const data of this.diffDataByPartId.values()) { + totalAdded += data.added; + totalRemoved += data.removed; + } + this._aggregatedDiff = { added: totalAdded, removed: totalRemoved }; + + // Re-render the finalized title if streaming is already complete, + // since diff events from edit pills may arrive after the title was set. + if (this.streamingCompleted || this.element.isComplete) { + this.setFinalizedTitle(this.currentTitle); + } + } + + private setFallbackTitle(): void { + const finalLabel = this.appendedItemCount > 0 + ? this.appendedItemCount === 1 + ? localize('chat.thinking.finished.withStepsSingular', 'Finished with 1 step') + : localize('chat.thinking.finished.withStepsPlural', 'Finished with {0} steps', this.appendedItemCount) + : localize('chat.thinking.finished', 'Finished Working'); + + this.currentTitle = finalLabel; + // With lazy rendering, wrapper may not be created yet if content hasn't been expanded + if (this.wrapper) { + this.wrapper.classList.remove('chat-thinking-streaming'); + } + this.domNode.classList.remove('chat-thinking-active'); + this.streamingCompleted = true; + + // Render any aggregated images that were deferred during fixed scrolling streaming. + this.flushPendingExternalResources(); + + if (this._collapseButton) { + this._collapseButton.icon = Codicon.checkCompact; + this.setFinalizedTitle(finalLabel); + } + + this.updateDropdownClickability(); + } + + /** + * Appends a tool invocation or content item to the thinking group. + * The factory is called lazily - only when the thinking section is expanded. + * If already expanded, the factory is called immediately. + * + * When the caller has already created the content part eagerly (for example, a + * pre-built `ChatMarkdownContentPart` wrapped in a factory), the caller MUST pass + * that part as `eagerDisposable` so it is registered on this thinking part + * immediately. Otherwise, if the thinking section is collapsed and the lazy item + * is never materialized (because the user never expands it), the eagerly-created + * part would leak: its disposable is only referenced from inside the factory's + * closure, which nothing ever calls. + */ + public appendItem( + factory: () => { domNode: HTMLElement; disposable?: IDisposable }, + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata, + originalParent?: HTMLElement, + onDidChangeDiff?: Event, + eagerDisposable?: IDisposable, + ): void { + this.processPendingRemovals(); + this.containsGroupedItems = true; + + // Track tool invocation metadata immediately (for title generation) + this.trackToolMetadata(toolInvocationId, toolInvocationOrMarkdown); + this.updateWorkingSpinnerVisibility(); + this.appendedItemCount++; + + // Listen for diff changes from edit pills + if (onDidChangeDiff && toolInvocationId) { + this.diffDataByPartId.set(toolInvocationId, { added: 0, removed: 0, resources: [] }); + this._register(onDidChangeDiff(data => { + this.diffDataByPartId.set(toolInvocationId, data); + this.updateAggregatedDiff(); + })); + } + + // Register any caller-owned disposable up-front so it is always cleaned up + // with this thinking part, even if the lazy item is never materialized. + if (eagerDisposable) { + this._register(eagerDisposable); + } + + // get random message based on tool type + if (this.workingSpinnerLabel) { + const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); + } + + // If expanded or has been expanded once, render immediately + if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { + const result = factory(); + this.appendItemToDOM(result.domNode, toolInvocationId, toolInvocationOrMarkdown, originalParent); + if (result.disposable) { + const toolCallId = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } else { + // Defer rendering until expanded + const item: ILazyToolItem = { + kind: 'tool', + lazy: new Lazy(factory), + toolInvocationId, + toolInvocationOrMarkdown, + originalParent, + isHook: !toolInvocationOrMarkdown && !!toolInvocationId, + }; + this.lazyItems.push(item); + } + + this.updateDropdownClickability(); + } + + public removeMaterializedItem(toolCallId: string): void { + this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.delete(toolCallId); + + const wrapper = this.toolWrappersByCallId.get(toolCallId); + if (wrapper) { + this.toolWrappersByCallId.delete(toolCallId); + this.toolIconsByCallId.delete(toolCallId); + } + + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); + + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId + ); + if (toolInvocationsIndex !== -1) { + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const label = this.toolLabelsByCallId.get(toolCallId); + if (label) { + const titleIndex = this.extractedTitles.indexOf(label); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + } + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + this.toolLabelsByCallId.delete(toolCallId); + + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + + this.updateWorkingSpinnerVisibility(); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + + /** + * Removes a markdown edit pill child by its part ID (codeblocksPartId). + */ + public removeEditPillByPartId(partId: string): void { + let removed = false; + + const lazyIndex = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === partId); + if (lazyIndex !== -1) { + this.lazyItems.splice(lazyIndex, 1); + removed = true; + } + + if (this.diffDataByPartId.delete(partId)) { + this.updateAggregatedDiff(); + removed = true; + } + + if (removed) { + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + } + + /** + * removes/re-establishes a lazy item from the thinking container + * this is needed so we can check if there are confirmations still needed + */ + public removeLazyItem(toolInvocationId: string): boolean { + const index = this.lazyItems.findIndex(item => item.kind === 'tool' && item.toolInvocationId === toolInvocationId); + if (index === -1) { + return false; + } + + const removedItem = this.lazyItems[index]; + this.lazyItems.splice(index, 1); + this.appendedItemCount--; + if (removedItem.kind === 'tool' && removedItem.isHook) { + this.hookCount = Math.max(0, this.hookCount - 1); + } else { + this.toolInvocationCount--; + } + + // Clear the attached-to-thinking flag on the removed tool invocation + if (removedItem.kind === 'tool' && removedItem.toolInvocationOrMarkdown && (removedItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { + removedItem.toolInvocationOrMarkdown.isAttachedToThinking = false; + + // Keep extractedTitles in sync when a lazy tool leaves the thinking container. + // Use the tracked displayed label (which may differ from invocationMessage + // for streaming edit tools that show "Editing files") + const toolCallId = removedItem.toolInvocationOrMarkdown.toolCallId; + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + const label = this.toolLabelsByCallId.get(toolCallId); + if (label) { + const titleIndex = this.extractedTitles.indexOf(label); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + } + this.toolLabelsByCallId.delete(toolCallId); + } + + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolId === toolInvocationId + ); + if (toolInvocationsIndex !== -1) { + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + + this.updateDropdownClickability(); + this.updateWorkingSpinnerVisibility(); + return true; + } + + private processPendingRemovals(): void { + this.pendingRemovalFlushDisposable?.dispose(); + this.pendingRemovalFlushDisposable = undefined; + + if (this.pendingRemovals.length === 0) { + return; + } + + const pendingRemovals = this.pendingRemovals; + this.pendingRemovals = []; + + for (const pending of pendingRemovals) { + this.removeStreamingToolEntry(pending.toolCallId, pending.toolLabel); + } + } + + private schedulePendingRemovalsFlush(): void { + if (this.pendingRemovalFlushDisposable) { + return; + } + + this.pendingRemovalFlushDisposable = scheduleAtNextAnimationFrame(getWindow(this.domNode), () => { + this.pendingRemovalFlushDisposable = undefined; + if (this._store.isDisposed) { + return; + } + + this.processPendingRemovals(); + }); + } + + // removes the tool entry that was previously streaming and now is not. removes item from dom and internal tracking. + private removeStreamingToolEntry(toolCallId: string, toolLabel: string): void { + this.toolDisposables.deleteAndDispose(toolCallId); + this.ownedToolParts.get(toolCallId)?.dispose(); + this.ownedToolParts.delete(toolCallId); + + const wrapper = this.toolWrappersByCallId.get(toolCallId); + if (wrapper) { + wrapper.remove(); + this.toolWrappersByCallId.delete(toolCallId); + this.toolIconsByCallId.delete(toolCallId); + } + + // make sure to remove any lazy item as well + const lazyIndex = this.lazyItems.findIndex(item => + item.kind === 'tool' && + item.toolInvocationOrMarkdown && + (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && + item.toolInvocationOrMarkdown.toolCallId === toolCallId + ); + if (lazyIndex !== -1) { + const removedLazyItem = this.lazyItems[lazyIndex]; + if (removedLazyItem.kind === 'tool' && removedLazyItem.toolInvocationOrMarkdown && (removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocation' || removedLazyItem.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized')) { + removedLazyItem.toolInvocationOrMarkdown.isAttachedToThinking = false; + } + this.lazyItems.splice(lazyIndex, 1); + } + + this.appendedItemCount = Math.max(0, this.appendedItemCount - 1); + this.toolInvocationCount = Math.max(0, this.toolInvocationCount - 1); + const toolInvocationsIndex = this.toolInvocations.findIndex(t => + (t.kind === 'toolInvocation' || t.kind === 'toolInvocationSerialized') && t.toolCallId === toolCallId + ); + if (toolInvocationsIndex !== -1) { + this.toolInvocations.splice(toolInvocationsIndex, 1); + } + + const titleIndex = this.extractedTitles.indexOf(toolLabel); + if (titleIndex !== -1) { + this.extractedTitles.splice(titleIndex, 1); + } + this.toolLabelsByCallId.delete(toolCallId); + this._pendingExternalResources.delete(toolCallId); + this._externalResourceWidget.removeToolInvocation(toolCallId); + this.updateWorkingSpinnerVisibility(); + this.updateDropdownClickability(); + this._onDidChangeHeight.fire(); + } + + private trackToolMetadata( + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata + ): void { + if (!toolInvocationId) { + return; + } + + // Track hooks separately: if toolInvocationOrMarkdown is undefined, it's a hook item + const isHook = !toolInvocationOrMarkdown; + if (isHook) { + this.hookCount++; + } else { + this.toolInvocationCount++; + } + + // Shift default title from 'Thinking' to 'Working' once we have tool calls + if (this.toolInvocationCount === 1) { + this.defaultTitle = this.workingTitle; + } + + let toolCallLabel: string; + let toolCallTitle: ChatThinkingTitle; + + const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); + if (isToolInvocation && toolInvocationOrMarkdown.invocationMessage) { + const invocationMessage = toolInvocationOrMarkdown.invocationMessage; + + // For edit-type tools that are still streaming, use a friendlier label + // instead of the generic tool display name (e.g. "Replace String in File") + const isStreamingEditTool = toolInvocationOrMarkdown.kind === 'toolInvocation' && IChatToolInvocation.isStreaming(toolInvocationOrMarkdown) && isGenericEditToolId(toolInvocationOrMarkdown.toolId); + if (isStreamingEditTool) { + toolCallTitle = localize('chat.thinking.editingFiles', 'Editing files'); + } else { + toolCallTitle = invocationMessage; + } + toolCallLabel = getThinkingTitleValue(toolCallTitle); + + this.toolInvocations.push(toolInvocationOrMarkdown); + + // Track the displayed label for consistent cleanup + const toolCallId = toolInvocationOrMarkdown.toolCallId; + this.toolLabelsByCallId.set(toolCallId, toolCallLabel); + + // Render external image pills for serialized (already-completed) tool invocations + if (toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + + // Queue hidden serialized tools for removal immediately. + if (IChatToolInvocation.isEffectivelyHidden(toolInvocationOrMarkdown)) { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: toolCallLabel }); + this.schedulePendingRemovalsFlush(); + } + } + + // track state for live/still streaming tools, excluding serialized tools + if (toolInvocationOrMarkdown.kind === 'toolInvocation') { + let currentToolLabel = toolCallLabel; + let isComplete = false; + let isStreaming = IChatToolInvocation.isStreaming(toolInvocationOrMarkdown); + + const toolStore = new DisposableStore(); + this.toolDisposables.set(toolInvocationOrMarkdown.toolCallId, toolStore); + + const updateTitle = (updatedTitle: ChatThinkingTitle) => { + const updatedMessage = getThinkingTitleValue(updatedTitle); + if (updatedMessage && !thinkingTitleEqual(updatedTitle, toolCallTitle)) { + // replace old title if exists, otherwise add new + if (updatedMessage !== currentToolLabel) { + const oldIndex = this.extractedTitles.indexOf(currentToolLabel); + const updatedIndex = this.extractedTitles.indexOf(updatedMessage); + + if (oldIndex !== -1) { + if (updatedIndex !== -1 && updatedIndex !== oldIndex) { + this.extractedTitles.splice(oldIndex, 1); + } else { + this.extractedTitles[oldIndex] = updatedMessage; + } + } else if (updatedIndex === -1) { + this.extractedTitles.push(updatedMessage); + } + currentToolLabel = updatedMessage; + } + toolCallLabel = updatedMessage; + toolCallTitle = updatedTitle; + this.toolLabelsByCallId.set(toolCallId, updatedMessage); + this.lastExtractedTitle = updatedMessage; + + // make sure not to set title if expanded + if (!this.fixedScrollingMode && !this._isExpanded.read(undefined)) { + this.setTitle(updatedTitle); + } + } + }; + + const autorunDisposable = autorun(reader => { + if (isComplete) { + return; + } + + const currentState = toolInvocationOrMarkdown.state.read(reader); + this.updateWorkingSpinnerVisibility(reader); + + // queue item to be removed if it was streaming and presentation is hidden + if (isStreaming && currentState.type !== IChatToolInvocation.StateKind.Streaming) { + isStreaming = false; + + // Update terminal tool icon based on sandbox wrapping state + const termData = toolInvocationOrMarkdown.toolSpecificData as IChatTerminalToolInvocationData | undefined; + if (termData?.kind === 'terminal') { + const iconEl = this.toolIconsByCallId.get(toolCallId); + if (iconEl) { + const newIcon = termData.commandLine?.isSandboxWrapped ? Codicon.terminalSecure : Codicon.terminal; + setThinkingIcon(iconEl, newIcon); + } + } + + if (toolInvocationOrMarkdown.presentation === 'hidden') { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); + this.schedulePendingRemovalsFlush(); + isComplete = true; + return; + } + } + + if (currentState.type === IChatToolInvocation.StateKind.Completed || + currentState.type === IChatToolInvocation.StateKind.Cancelled) { + // Remove tools that should be hidden now or after completion. + if (toolInvocationOrMarkdown.presentation === 'hidden' || toolInvocationOrMarkdown.presentation === 'hiddenAfterComplete') { + this.pendingRemovals.push({ toolCallId: toolInvocationOrMarkdown.toolCallId, toolLabel: currentToolLabel }); + this.schedulePendingRemovalsFlush(); + } + + // Render image pills outside the collapsible area for completed tools + if (currentState.type === IChatToolInvocation.StateKind.Completed) { + this.updateExternalResourceParts(toolInvocationOrMarkdown); + const completedMessage = toolInvocationOrMarkdown.pastTenseMessage ?? toolInvocationOrMarkdown.invocationMessage; + const completedText = typeof completedMessage === 'string' ? completedMessage : completedMessage.value; + const iconElement = this.toolIconsByCallId.get(toolCallId); + if (iconElement && isNoProblemsFoundResult(toolInvocationOrMarkdown.toolId, completedText)) { + setThinkingIcon(iconElement, Codicon.search); + } + } + + isComplete = true; + return; + } + + // streaming + if (currentState.type === IChatToolInvocation.StateKind.Streaming) { + isStreaming = true; + const streamingMessage = currentState.streamingMessage.read(reader); + if (streamingMessage) { + updateTitle(streamingMessage); + } + return; + } + + // executing (something like `Replacing 67 lines.....`) + if (currentState.type === IChatToolInvocation.StateKind.Executing) { + const progressData = currentState.progress.read(reader); + if (progressData.message) { + updateTitle(progressData.message); + } else { + const invocationMsg = toolInvocationOrMarkdown.invocationMessage; + if (invocationMsg) { + updateTitle(invocationMsg); + } + } + return; + } + + // confirmations, failures, completed, other, etc + const invocationMsg = toolInvocationOrMarkdown.invocationMessage; + if (invocationMsg) { + updateTitle(invocationMsg); + } + }); + toolStore.add(autorunDisposable); + } + } else if (toolInvocationOrMarkdown?.kind === 'markdownContent') { + const codeblockInfo = extractCodeblockUrisFromText(toolInvocationOrMarkdown.content.value); + if (codeblockInfo?.uri) { + const filename = basename(codeblockInfo.uri); + toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); + } else { + toolCallLabel = localize('chat.thinking.editingFile', 'Edited file'); + } + toolCallTitle = toolCallLabel; + } else if (toolInvocationOrMarkdown?.kind === 'externalEdit') { + const filename = basename(toolInvocationOrMarkdown.uri); + switch (toolInvocationOrMarkdown.editKind) { + case 'create': + toolCallLabel = localize('chat.thinking.createdFile', 'Created {0}', filename); + break; + case 'delete': + toolCallLabel = localize('chat.thinking.deletedFile', 'Deleted {0}', filename); + break; + case 'rename': + toolCallLabel = localize('chat.thinking.renamedFile', 'Renamed {0}', filename); + break; + case 'edit': + toolCallLabel = localize('chat.thinking.editedFile', 'Edited {0}', filename); + break; + } + toolCallTitle = toolCallLabel; + } else { + toolCallLabel = toolInvocationId; + toolCallTitle = toolCallLabel; + } + + // Add tool call to extracted titles for LLM title generation + if (!this.extractedTitles.includes(toolCallLabel)) { + this.extractedTitles.push(toolCallLabel); + } + + this.lastExtractedTitle = toolCallLabel; + + if (!this.fixedScrollingMode && !this._isExpanded.get()) { + this.setTitle(toolCallTitle); + } + } + + private updateExternalResourceParts(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): void { + if (toolInvocation.toolSpecificData?.kind === 'terminal') { + return; + } + + // In fixed scrolling mode, defer rendering aggregated images at the bottom while + // the response is still streaming. The images would otherwise overlap the pinned + // scrolling viewport. They are flushed once streaming completes. + if (this.fixedScrollingMode && !this.streamingCompleted && !this.element.isComplete) { + this._pendingExternalResources.set(toolInvocation.toolCallId, toolInvocation); + return; + } + + const extractedImages = extractImagesFromToolInvocationOutputDetails(toolInvocation, this.element.sessionResource); + if (extractedImages.length === 0) { + return; + } + + const parts: IChatCollapsibleIODataPart[] = extractedImages.map(image => ({ + kind: 'data', + value: image.data.buffer, + mimeType: image.mimeType, + uri: image.uri, + })); + + this._externalResourceWidget.setToolInvocationParts(toolInvocation.toolCallId, parts); + } + + private flushPendingExternalResources(): void { + if (this._pendingExternalResources.size === 0) { + return; + } + const pending = Array.from(this._pendingExternalResources.values()); + this._pendingExternalResources.clear(); + for (const toolInvocation of pending) { + this.updateExternalResourceParts(toolInvocation); + } + } + + private appendItemToDOM( + content: HTMLElement, + toolInvocationId?: string, + toolInvocationOrMarkdown?: ChatThinkingItemMetadata, + originalParent?: HTMLElement + ): void { + if (!content.hasChildNodes() || content.textContent?.trim() === '') { + return; + } + + const itemWrapper = $('.chat-thinking-tool-wrapper'); + const isMarkdownEdit = toolInvocationOrMarkdown?.kind === 'markdownContent'; + const isExternalEdit = toolInvocationOrMarkdown?.kind === 'externalEdit'; + const isTerminalTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const isSearchTool = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && toolInvocationOrMarkdown.toolSpecificData?.kind === 'search'; + const toolInvocationIcon = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown.icon : undefined; + + let icon: ThemeIcon; + if (isNoProblemsFoundResult(toolInvocationId, content.textContent ?? undefined)) { + icon = Codicon.search; + } else if (isMarkdownEdit || isExternalEdit) { + icon = Codicon.pencil; + } else if (isSearchTool) { + icon = Codicon.search; + } else if (isTerminalTool) { + const terminalData = (toolInvocationOrMarkdown as IChatToolInvocation | IChatToolInvocationSerialized).toolSpecificData as { kind: 'terminal'; terminalCommandState?: { exitCode?: number }; commandLine?: { isSandboxWrapped?: boolean } }; + const exitCode = terminalData?.terminalCommandState?.exitCode; + const isSandboxWrapped = terminalData?.commandLine?.isSandboxWrapped; + if (exitCode !== undefined && exitCode !== 0) { + icon = Codicon.error; + } else if (isSandboxWrapped) { + icon = Codicon.terminalSecure; + } else { + icon = toolInvocationIcon ?? Codicon.terminal; + } + } else if (content.classList.contains('chat-hook-outcome-blocked')) { + icon = Codicon.error; + } else if (content.classList.contains('chat-hook-outcome-warning')) { + icon = Codicon.warning; + } else { + icon = toolInvocationId ? getToolInvocationIcon(toolInvocationId, toolInvocationIcon, content.textContent ?? undefined) : Codicon.tools; + } + + const iconElement = createThinkingIcon(icon); + itemWrapper.appendChild(iconElement); + itemWrapper.appendChild(content); + + if (this.toolInvocationCount === 1 && this.hookCount === 0 && originalParent) { + const toolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? toolInvocationOrMarkdown : undefined; + this.singleItemInfo = { + element: content, + thinkingWrapper: itemWrapper, + originalParent, + originalNextSibling: this.domNode, + restoreToOriginalParent: !!toolInvocation || isExternalEdit, + toolInvocation + }; + } else { + this.singleItemInfo = undefined; + } + + const isToolInvocation = toolInvocationOrMarkdown && (toolInvocationOrMarkdown.kind === 'toolInvocation' || toolInvocationOrMarkdown.kind === 'toolInvocationSerialized'); + if (isToolInvocation && toolInvocationOrMarkdown.toolCallId) { + this.toolWrappersByCallId.set(toolInvocationOrMarkdown.toolCallId, itemWrapper); + this.toolIconsByCallId.set(toolInvocationOrMarkdown.toolCallId, iconElement); + } + + this.appendToWrapper(itemWrapper); + + if (this.fixedScrollingMode && this.scrollableElement) { + // Observe the child wrapper for resizes (e.g. terminal expanding) + if (this.childResizeObserver && !this.streamingCompleted) { + const observeDisposable = this.childResizeObserver.observe(itemWrapper); + const toolCallId = isToolInvocation ? toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + let store = this.toolDisposables.get(toolCallId); + if (!store) { + store = new DisposableStore(); + this.toolDisposables.set(toolCallId, store); + } + store.add(observeDisposable); + } else { + this._register(observeDisposable); + } + } + + // Coalesce reads of scrollHeight to avoid forced reflows when many items + // are appended in the same tick (e.g. when restoring a session). + this.scheduleAppendRefresh(); + } + } + + private scheduleAppendRefresh(): void { + if (this._pendingAppendRefresh.value) { + return; + } + this._pendingAppendRefresh.value = scheduleAtNextAnimationFrame(getWindow(this.wrapper), () => { + this._pendingAppendRefresh.clear(); + if (this._store.isDisposed) { + return; + } + this.refreshContentHeight(); + this.updateScrollDimensionsFromCache(); + }); + } + + private materializeLazyItem(item: ILazyItem): void { + if (item.kind === 'thinking') { + // Materialize thinking container + this.appendToWrapper(item.textContainer); + // Store reference to textContainer for updateThinking calls + this.textContainer = item.textContainer; + this.id = item.content.id; + // Use item.content which is kept up-to-date during streaming via updateThinking + this.updateThinking(item.content); + return; + } + + if (this.workingSpinnerLabel) { + const isTerminalTool = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') && item.toolInvocationOrMarkdown.toolSpecificData?.kind === 'terminal'; + const category = isTerminalTool ? WorkingMessageCategory.Terminal : WorkingMessageCategory.Tool; + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(category); + } + + // Handle tool items + if (item.lazy.hasValue) { + // Already evaluated — but may not have been placed in the DOM yet + // (e.g. finalizeTitleIfDefault materialized it before the wrapper existed). + const result = item.lazy.value; + if (!result.domNode.parentElement) { + this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); + } + return; + } + + const result = item.lazy.value; + this.appendItemToDOM(result.domNode, item.toolInvocationId, item.toolInvocationOrMarkdown, item.originalParent); + + if (result.disposable) { + const toolCallId = item.toolInvocationOrMarkdown && (item.toolInvocationOrMarkdown.kind === 'toolInvocation' || item.toolInvocationOrMarkdown.kind === 'toolInvocationSerialized') ? item.toolInvocationOrMarkdown.toolCallId : undefined; + if (toolCallId) { + this.ownedToolParts.set(toolCallId, result.disposable); + } else { + this._register(result.disposable); + } + } + } + + // makes a new text container. when we update, we now update this container. + public setupThinkingContainer(content: IChatThinkingPart) { + // Avoid creating new containers after disposal + if (this._store.isDisposed) { + return; + } + this.appendedItemCount++; + this.allThinkingParts.push(content); + const contentText = extractTextFromPart(content); + this.recordReasoningContent(contentText); + // First-writer wins: a later grouped block can be the first multi-header + // summary (when earlier blocks had <2 headers), so track it here too — the + // lazy/reload path never routes through updateThinking. + this.trackDroppedSummaryHeader(contentText); + this.textContainer = $('.chat-thinking-item.markdown-content'); + // Observe the new textContainer for child resizes in fixed scrolling mode + if (this.childResizeObserver && this.fixedScrollingMode && !this.streamingCompleted) { + this._register(this.childResizeObserver.observe(this.textContainer)); + } + if (content.value) { + // Use lazy rendering when collapsed to preserve order with tool items + if (this.isExpanded() || this.hasExpandedOnce || (this.fixedScrollingMode && !this.streamingCompleted)) { + // Render immediately when expanded + this.appendToWrapper(this.textContainer); + this.id = content.id; + this.updateThinking(content); + } else { + // Update this.content and this.id so that subsequent updateThinking calls + // or materializeLazyItem will use the correct content for this section + this.content = content; + this.id = content.id; + // Defer rendering until expanded to preserve order + const lazyThinking: ILazyThinkingItem = { + kind: 'thinking', + textContainer: this.textContainer, + content + }; + this.lazyItems.push(lazyThinking); + } + + if (this.workingSpinnerLabel) { + this.workingSpinnerLabel.textContent = this.getRandomWorkingMessage(WorkingMessageCategory.Thinking); + } + } + this.updateDropdownClickability(); + } + + protected override setTitle(title: ChatThinkingTitle, omitPrefix?: boolean): void { + const titleValue = getThinkingTitleValue(title); + if (!titleValue || this.element.isComplete) { + return; + } + + if (omitPrefix) { + this.clearTitleDetail(); + if (this._collapseButton) { + const labelElement = this._collapseButton.labelElement; + labelElement.textContent = ''; + const plainSpan = $('span'); + plainSpan.textContent = titleValue; + labelElement.appendChild(plainSpan); + this._collapseButton.element.ariaLabel = titleValue; + } + this.forgetShimmerTitle(); + this.currentTitle = titleValue; + return; + } + + this.lastExtractedTitle = titleValue; + this.lastRenderedTitle = title; + this.currentTitle = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, titleValue); + + if (!this._collapseButton) { + return; + } + + const labelElement = this._collapseButton.labelElement; + + this.setShimmerTitle(localize('chat.thinking.shimmer', "{0}: ", this.defaultTitle)); + + // Dispose previous detail rendering + this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); + + const markdownTitle = typeof title === 'string' ? new MarkdownString(title) : title; + const result = this.chatContentMarkdownRenderer.render(markdownTitle); + result.element.classList.add('collapsible-title-content', 'chat-thinking-title-detail'); + renderFileWidgets(result.element, this.instantiationService, this.chatMarkdownAnchorService, this._titleFileWidgetStore); + this._titleFileWidgetStore.add(addDisposableListener(result.element, EventType.CLICK, event => { + if (isHTMLElement(event.target) && event.target.closest('a, input')) { + return; + } + EventHelper.stop(event, true); + this.toggleExpanded(); + })); + this._titleDetailRendered.value = result; + + const previousTitleDetail = this.titleDetailContainer; + // eslint-disable-next-line no-restricted-syntax + const hasTitleLinks = result.element.querySelector('a') !== null; + if (hasTitleLinks) { + const container = this._collapseButton.element.parentElement; + if (container) { + if (this._hoverChevron) { + container.appendChild(this._hoverChevron); + } + container.insertBefore(result.element, this.diffButton?.element ?? this._hoverChevron ?? null); + } + } else { + labelElement.appendChild(result.element); + if (!this.diffButton && this._hoverChevron) { + this._collapseButton.element.appendChild(this._hoverChevron); + } + } + previousTitleDetail?.remove(); + this.titleDetailContainer = result.element; + + const renderedTitle = result.element.textContent?.trim() || titleValue; + const thinkingLabel = localize('chat.thinking.label', "{0}: {1}", this.defaultTitle, renderedTitle); + this._collapseButton.element.ariaLabel = thinkingLabel; + this._collapseButton.element.ariaExpanded = String(this.isExpanded()); + } + + private clearTitleDetail(): void { + this.titleDetailContainer?.remove(); + this.titleDetailContainer = undefined; + this._titleDetailRendered.clear(); + this._titleFileWidgetStore.clear(); + } + + hasSameContent(other: IChatRendererContent, _followingContent: IChatRendererContent[], _element: ChatTreeItem): boolean { + + if (_element.isComplete) { + return true; + } + if ((other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized') + && other.toolSpecificData?.kind === 'subagent' + && !other.subAgentInvocationId) { + return false; + } + + if (other.kind === 'toolInvocation' || other.kind === 'toolInvocationSerialized' || other.kind === 'markdownContent' || other.kind === 'hook') { + return true; + } + + if (other.kind !== 'thinking') { + return false; + } + + return other?.id !== this.id; + } + + override dispose(): void { + this.isActive = false; + if (this.workingSpinnerElement) { + this.workingSpinnerElement.remove(); + this.workingSpinnerElement = undefined; + this.workingSpinnerLabel = undefined; + } + this.pendingRemovalFlushDisposable?.dispose(); + this.pendingRemovalFlushDisposable = undefined; + this.pendingScrollDisposable?.dispose(); + super.dispose(); + } +}