diff --git a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts index 98e7eb77ecc..4414c638fe6 100644 --- a/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts +++ b/src/vs/workbench/contrib/chat/browser/widget/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { h } from '../../../../../../../base/browser/dom.js'; -import { ActionBar } from '../../../../../../../base/browser/ui/actionbar/actionbar.js'; import { isMarkdownString, MarkdownString } from '../../../../../../../base/common/htmlContent.js'; import { IConfigurationService } from '../../../../../../../platform/configuration/common/configuration.js'; import { IInstantiationService } from '../../../../../../../platform/instantiation/common/instantiation.js'; @@ -25,7 +24,7 @@ import { ChatCollapsibleContentPart } from '../chatCollapsibleContentPart.js'; import { IChatRendererContent } from '../../../../common/model/chatViewModel.js'; import '../media/chatTerminalToolProgressPart.css'; import type { ICodeBlockRenderOptions } from '../codeBlockPart.js'; -import { Action, IAction } from '../../../../../../../base/common/actions.js'; +import { IAction } from '../../../../../../../base/common/actions.js'; import { timeout } from '../../../../../../../base/common/async.js'; import { IChatTerminalToolProgressPart, ITerminalChatService, ITerminalConfigurationService, ITerminalEditorService, ITerminalGroupService, ITerminalInstance, ITerminalService } from '../../../../../terminal/browser/terminal.js'; import { Disposable, DisposableStore, MutableDisposable, toDisposable, type IDisposable } from '../../../../../../../base/common/lifecycle.js'; @@ -58,6 +57,11 @@ import { removeAnsiEscapeCodes } from '../../../../../../../base/common/strings. import { PANEL_BACKGROUND } from '../../../../../../common/theme.js'; import { editorBackground } from '../../../../../../../platform/theme/common/colorRegistry.js'; import { IThemeService } from '../../../../../../../platform/theme/common/themeService.js'; +import { MenuWorkbenchToolBar } from '../../../../../../../platform/actions/browser/toolbar.js'; +import { MenuRegistry } from '../../../../../../../platform/actions/common/actions.js'; +import { CommandsRegistry } from '../../../../../../../platform/commands/common/commands.js'; +import { MENU_CHAT_TERMINAL_TOOL_PROGRESS, TerminalChatContextKeys } from '../../../../../terminal/terminalContribChatExports.js'; +import { ServiceCollection } from '../../../../../../../platform/instantiation/common/serviceCollection.js'; /** * Minimum number of rows to display in the terminal output view. @@ -94,6 +98,62 @@ const MIN_DATA_EVENTS_FOR_REAL_OUTPUT = 2; */ const expandedStateByInvocation = new WeakMap(); +// --- Command and menu registrations for terminal tool progress toolbar --- + +CommandsRegistry.registerCommand(TerminalContribCommandId.FocusChatInstanceAction, async (_accessor: unknown, progressPart?: IChatTerminalToolProgressPart) => { + await progressPart?.focusTerminal(); +}); + +CommandsRegistry.registerCommand(TerminalContribCommandId.ContinueInBackground, async (_accessor: unknown, progressPart?: IChatTerminalToolProgressPart) => { + progressPart?.continueInBackground(); +}); + +CommandsRegistry.registerCommand(TerminalContribCommandId.ToggleChatTerminalOutput, async (_accessor: unknown, progressPart?: IChatTerminalToolProgressPart) => { + await progressPart?.toggleOutputFromAction(); +}); + +MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { + command: { + id: TerminalContribCommandId.ContinueInBackground, + title: localize('continueInBackground', 'Continue in Background'), + icon: Codicon.debugContinue, + }, + when: TerminalChatContextKeys.chatToolCanContinueInBackground, + order: 0, + group: 'navigation', +}); + +MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { + command: { + id: TerminalContribCommandId.FocusChatInstanceAction, + title: localize('focusTerminal', 'Focus Terminal'), + icon: Codicon.openInProduct, + toggled: { + condition: TerminalChatContextKeys.chatToolIsHiddenTerminal, + title: localize('showTerminal', 'Show and Focus Terminal'), + } + }, + when: TerminalChatContextKeys.chatToolHasInstance, + order: 1, + group: 'navigation', +}); + +MenuRegistry.appendMenuItem(MENU_CHAT_TERMINAL_TOOL_PROGRESS, { + command: { + id: TerminalContribCommandId.ToggleChatTerminalOutput, + title: localize('showTerminalOutput', 'Show Output'), + icon: Codicon.chevronRight, + toggled: { + condition: TerminalChatContextKeys.chatToolOutputExpanded, + title: localize('hideTerminalOutput', 'Hide Output'), + icon: Codicon.chevronDown, + } + }, + when: TerminalChatContextKeys.chatToolHasOutput.isEqualTo(true), + order: 2, + group: 'navigation', +}); + /** * Options for configuring a terminal command decoration. */ @@ -247,8 +307,6 @@ class TerminalCommandDecoration extends Disposable { export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart implements IChatTerminalToolProgressPart { public readonly domNode: HTMLElement; - private readonly _actionBar: ActionBar; - private readonly _titleElement: HTMLElement; private readonly _outputView: ChatTerminalToolOutputSection; private readonly _terminalOutputContextKey: IContextKey; @@ -257,14 +315,15 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart private readonly _contentIndex: number; private readonly _sessionResource: URI; - private readonly _showOutputAction = this._register(new MutableDisposable()); - private _showOutputActionAdded = false; - private readonly _focusAction = this._register(new MutableDisposable()); - private readonly _continueInBackgroundAction = this._register(new MutableDisposable()); + // Scoped context keys that drive toolbar action visibility + private readonly _hasInstanceKey: IContextKey; + private readonly _canContinueInBackgroundKey: IContextKey; + private readonly _hasOutputKey: IContextKey; + private readonly _isHiddenTerminalKey: IContextKey; + private readonly _outputExpandedKey: IContextKey; private readonly _terminalData: IChatTerminalToolInvocationData; private _terminalCommandUri: URI | undefined; - private _storedCommandId: string | undefined; private readonly _commandText: string; private readonly _isSerializedInvocation: boolean; private _terminalInstance: ITerminalInstance | undefined; @@ -302,6 +361,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart @IChatWidgetService private readonly _chatWidgetService: IChatWidgetService, @IKeybindingService private readonly _keybindingService: IKeybindingService, @IConfigurationService private readonly _configurationService: IConfigurationService, + @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, + @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, + @ITelemetryService private readonly _telemetryService: ITelemetryService, ) { super(toolInvocation); @@ -312,7 +374,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart terminalData = migrateLegacyTerminalToolSpecificData(terminalData); this._terminalData = terminalData; this._terminalCommandUri = terminalData.terminalCommandUri ? URI.revive(terminalData.terminalCommandUri) : undefined; - this._storedCommandId = this._terminalCommandUri ? new URLSearchParams(this._terminalCommandUri.query ?? '').get('command') ?? undefined : undefined; this._isSerializedInvocation = (toolInvocation.kind === 'toolInvocationSerialized'); const elements = h('.chat-terminal-content-part@container', [ @@ -368,17 +429,52 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._register(this._outputView.onDidFocus(() => this._handleOutputFocus())); this._register(this._outputView.onDidBlur(e => this._handleOutputBlur(e))); this._register(toDisposable(() => this._handleDispose())); - this._register(this._keybindingService.onDidUpdateKeybindings(() => { - this._focusAction.value?.refreshKeybindingTooltip(); - this._showOutputAction.value?.refreshKeybindingTooltip(); - })); - + // Create a scoped context key service for this toolbar so each progress part's + // context keys are independent from other parts. const actionBarEl = h('.chat-terminal-action-bar@actionBar'); elements.title.append(actionBarEl.root); - this._actionBar = this._register(new ActionBar(actionBarEl.actionBar, {})); + const toolbarContextKeyService = this._register(this._contextKeyService.createScoped(actionBarEl.actionBar)); + this._hasInstanceKey = TerminalChatContextKeys.chatToolHasInstance.bindTo(toolbarContextKeyService); + this._canContinueInBackgroundKey = TerminalChatContextKeys.chatToolCanContinueInBackground.bindTo(toolbarContextKeyService); + this._hasOutputKey = TerminalChatContextKeys.chatToolHasOutput.bindTo(toolbarContextKeyService); + this._isHiddenTerminalKey = TerminalChatContextKeys.chatToolIsHiddenTerminal.bindTo(toolbarContextKeyService); + this._outputExpandedKey = TerminalChatContextKeys.chatToolOutputExpanded.bindTo(toolbarContextKeyService); + const usesCollapsibleKey = TerminalChatContextKeys.chatToolUsesCollapsible.bindTo(toolbarContextKeyService); + + const scopedInstantiationService = this._register(this._instantiationService.createChild( + new ServiceCollection([IContextKeyService, toolbarContextKeyService]) + )); + this._register(scopedInstantiationService.createInstance( + MenuWorkbenchToolBar, + actionBarEl.actionBar, + MENU_CHAT_TERMINAL_TOOL_PROGRESS, + { + menuOptions: { arg: this, shouldForwardArgs: true }, + getKeyBinding: (action: IAction) => { + if (action.id === TerminalContribCommandId.FocusChatInstanceAction) { + return this._keybindingService.lookupKeybinding(TerminalContribCommandId.FocusMostRecentChatTerminal); + } + if (action.id === TerminalContribCommandId.ToggleChatTerminalOutput) { + return this._keybindingService.lookupKeybinding(TerminalContribCommandId.FocusMostRecentChatTerminalOutput); + } + return undefined; + }, + } + )); this._initializeTerminalActions(); this._terminalService.whenConnected.then(() => this._initializeTerminalActions()); + + // Listen for continue in background — sets context key so toolbar auto-hides the action + const terminalToolSessionId = this._terminalData.terminalToolSessionId; + if (terminalToolSessionId) { + this._register(this._terminalChatService.onDidContinueInBackground(sessionId => { + if (sessionId === terminalToolSessionId) { + this._terminalData.didContinueInBackground = true; + this._canContinueInBackgroundKey.set(false); + } + })); + } let pastTenseMessage: string | undefined; if (toolInvocation.pastTenseMessage) { pastTenseMessage = `${typeof toolInvocation.pastTenseMessage === 'string' ? toolInvocation.pastTenseMessage : toolInvocation.pastTenseMessage.value}`; @@ -420,6 +516,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const requiresConfirmation = toolInvocation.kind === 'toolInvocation' && IChatToolInvocation.getConfirmationMessages(toolInvocation); this._isInThinkingContainer = terminalToolsInThinking && !requiresConfirmation; this._usesCollapsibleWrapper = this._isInThinkingContainer || isSimpleTerminal; + usesCollapsibleKey.set(this._usesCollapsibleWrapper); if (this._usesCollapsibleWrapper) { this.domNode = this._createCollapsibleWrapper(progressPart.domNode, displayCommand, toolInvocation, context); @@ -539,7 +636,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } const terminalToolSessionId = this._terminalData.terminalToolSessionId; if (!terminalToolSessionId) { - this._addActions(); + this._updateToolbarContextKeys(); return; } @@ -551,7 +648,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart if (this._isSerializedInvocation) { this._clearCommandAssociation(); } - this._addActions(undefined, terminalToolSessionId); + this._updateToolbarContextKeys(undefined, terminalToolSessionId); return; } const isNewInstance = this._terminalInstance !== instance; @@ -559,16 +656,14 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart this._terminalInstance = instance; this._registerInstanceListener(instance); } - // Always call _addActions to ensure actions are added, even if instance was set earlier - // (e.g., by the output view during expanded state restoration) - this._addActions(instance, terminalToolSessionId); + this._updateToolbarContextKeys(instance, terminalToolSessionId); }; const initialInstance = await this._terminalChatService.getTerminalInstanceByToolSessionId(terminalToolSessionId); await attachInstance(initialInstance); if (!initialInstance) { - this._addActions(undefined, terminalToolSessionId); + this._updateToolbarContextKeys(undefined, terminalToolSessionId); } if (this._store.isDisposed) { @@ -587,45 +682,50 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart }); this._terminalSessionRegistration = this._store.add(listener); } - - // Listen for continue in background to remove the button - this._store.add(this._terminalChatService.onDidContinueInBackground(sessionId => { - if (sessionId === terminalToolSessionId) { - this._terminalData.didContinueInBackground = true; - this._removeContinueInBackgroundAction(); - } - })); } - private _addActions(terminalInstance?: ITerminalInstance, terminalToolSessionId?: string): void { + /** + * Updates the scoped context keys that drive toolbar action visibility. + * The `MenuWorkbenchToolBar` automatically shows/hides actions based on these keys. + */ + private _updateToolbarContextKeys(terminalInstance?: ITerminalInstance, terminalToolSessionId?: string): void { if (this._store.isDisposed) { return; } - const actionBar = this._actionBar; - this._removeFocusAction(); const resolvedCommand = this._getResolvedCommand(terminalInstance); - this._removeContinueInBackgroundAction(); - if (terminalInstance) { - const isTerminalHidden = terminalInstance && terminalToolSessionId ? this._terminalChatService.isBackgroundTerminal(terminalToolSessionId) : false; - const focusAction = this._instantiationService.createInstance(FocusChatInstanceAction, terminalInstance, resolvedCommand, this._terminalCommandUri, this._storedCommandId, isTerminalHidden); - this._focusAction.value = focusAction; - actionBar.push(focusAction, { icon: true, label: false, index: 0 }); + // Focus terminal action + this._hasInstanceKey.set(!!terminalInstance); + if (terminalInstance && terminalToolSessionId) { + this._isHiddenTerminalKey.set(this._terminalChatService.isBackgroundTerminal(terminalToolSessionId)); + } else { + this._isHiddenTerminalKey.set(false); + } - // Add continue in background action - only for foreground executions with running commands - // Note: isBackground refers to whether the tool was invoked with isBackground=true (background execution), - // not whether the terminal is hidden from the user - if (terminalToolSessionId && !this._terminalData.isBackground && !this._terminalData.didContinueInBackground) { - const isStillRunning = resolvedCommand?.exitCode === undefined && this._terminalData.terminalCommandState?.exitCode === undefined; - if (isStillRunning) { - const continueAction = this._instantiationService.createInstance(ContinueInBackgroundAction, terminalToolSessionId); - this._continueInBackgroundAction.value = continueAction; - actionBar.push(continueAction, { icon: true, label: false, index: 0 }); + // Continue in background action + if (terminalInstance && terminalToolSessionId && !this._terminalData.isBackground && !this._terminalData.didContinueInBackground) { + const isStillRunning = resolvedCommand?.exitCode === undefined && this._terminalData.terminalCommandState?.exitCode === undefined; + this._canContinueInBackgroundKey.set(isStillRunning); + } else { + this._canContinueInBackgroundKey.set(false); + } + + // Show output action (only when NOT using collapsible wrapper) + if (!this._usesCollapsibleWrapper) { + const hasSnapshot = !!this._terminalData.terminalCommandOutput; + const hasOutput = !!resolvedCommand || hasSnapshot; + this._hasOutputKey.set(hasOutput); + + // Auto-expand on first detection of failed output + if (hasOutput && !this._outputView.isExpanded) { + const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); + const exitCode = resolvedCommand?.exitCode ?? this._terminalData.terminalCommandState?.exitCode; + if (exitCode !== undefined && exitCode !== 0 && autoExpandFailures) { + this._toggleOutput(true); } } } - this._ensureShowOutputAction(resolvedCommand); this._decoration.update(resolvedCommand); } @@ -637,52 +737,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart return this._resolveCommand(target); } - private _ensureShowOutputAction(command?: ITerminalCommand): void { - if (this._store.isDisposed) { - return; - } - // don't show dropdown when rendered with the simplified/collapsible wrapper - if (this._usesCollapsibleWrapper) { - return; - } - const resolvedCommand = command ?? this._getResolvedCommand(); - const hasSnapshot = !!this._terminalData.terminalCommandOutput; - if (!resolvedCommand && !hasSnapshot) { - return; - } - let showOutputAction = this._showOutputAction.value; - if (!showOutputAction) { - showOutputAction = this._instantiationService.createInstance(ToggleChatTerminalOutputAction, () => this._toggleOutputFromAction()); - this._showOutputAction.value = showOutputAction; - const autoExpandFailures = this._configurationService.getValue(ChatConfiguration.AutoExpandToolFailures); - const exitCode = resolvedCommand?.exitCode ?? this._terminalData.terminalCommandState?.exitCode; - if (exitCode !== undefined && exitCode !== 0 && autoExpandFailures) { - this._toggleOutput(true); - } - } - showOutputAction.syncPresentation(this._outputView.isExpanded); - - const actionBar = this._actionBar; - if (this._showOutputActionAdded) { - const existingIndex = actionBar.viewItems.findIndex(item => item.action === showOutputAction); - if (existingIndex >= 0 && existingIndex !== actionBar.length() - 1) { - actionBar.pull(existingIndex); - this._showOutputActionAdded = false; - } else if (existingIndex >= 0) { - return; - } - } - - if (this._showOutputActionAdded) { - return; - } - actionBar.push([showOutputAction], { icon: true, label: false }); - this._showOutputActionAdded = true; - } - private _clearCommandAssociation(options?: { clearPersistentData?: boolean }): void { this._terminalCommandUri = undefined; - this._storedCommandId = undefined; if (options?.clearPersistentData) { if (this._terminalData.terminalCommandUri) { delete this._terminalData.terminalCommandUri; @@ -719,7 +775,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const commandDetectionListener = this._register(new MutableDisposable()); const tryResolveCommand = async (): Promise => { const resolvedCommand = this._resolveCommand(terminalInstance); - this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + this._updateToolbarContextKeys(terminalInstance, this._terminalData.terminalToolSessionId); return resolvedCommand; }; @@ -778,11 +834,11 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart })); store.add(commandDetection.onCommandExecuted(() => { - this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + this._updateToolbarContextKeys(terminalInstance, this._terminalData.terminalToolSessionId); })); store.add(commandDetection.onCommandFinished(() => { - this._addActions(terminalInstance, this._terminalData.terminalToolSessionId); + this._updateToolbarContextKeys(terminalInstance, this._terminalData.terminalToolSessionId); const resolvedCommand = this._getResolvedCommand(terminalInstance); this._handleCommandCompletion(resolvedCommand); @@ -810,47 +866,11 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } this._clearCommandAssociation({ clearPersistentData: true }); commandDetectionListener.clear(); - if (!this._store.isDisposed) { - this._actionBar.clear(); - } - this._removeFocusAction(); - this._showOutputActionAdded = false; - this._showOutputAction.clear(); - this._addActions(undefined, this._terminalData.terminalToolSessionId); + this._updateToolbarContextKeys(undefined, this._terminalData.terminalToolSessionId); instanceListener.dispose(); })); } - private _removeFocusAction(): void { - if (this._store.isDisposed) { - return; - } - const actionBar = this._actionBar; - const focusAction = this._focusAction.value; - if (actionBar && focusAction) { - const existingIndex = actionBar.viewItems.findIndex(item => item.action === focusAction); - if (existingIndex >= 0) { - actionBar.pull(existingIndex); - } - } - this._focusAction.clear(); - } - - private _removeContinueInBackgroundAction(): void { - if (this._store.isDisposed) { - return; - } - const actionBar = this._actionBar; - const continueAction = this._continueInBackgroundAction.value; - if (actionBar && continueAction) { - const existingIndex = actionBar.viewItems.findIndex(item => item.action === continueAction); - if (existingIndex >= 0) { - actionBar.pull(existingIndex); - } - } - this._continueInBackgroundAction.clear(); - } - /** * Handles the completion of a terminal command by updating the UI state. * This includes marking the collapsible wrapper as complete, auto-collapsing @@ -878,7 +898,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart const didChange = await this._outputView.toggle(expanded); const isExpanded = this._outputView.isExpanded; this._titleElement.classList.toggle('chat-terminal-content-title-no-bottom-radius', isExpanded); - this._showOutputAction.value?.syncPresentation(isExpanded); + this._outputExpandedKey.set(isExpanded); if (didChange) { expandedStateByInvocation.set(this.toolInvocation, isExpanded); } @@ -932,15 +952,80 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart } public async focusTerminal(): Promise { - if (this._focusAction.value) { - await this._focusAction.value.run(); + const instance = this._terminalInstance; + + type FocusChatInstanceTelemetryEvent = { + target: 'instance' | 'commandUri' | 'none'; + location: 'panel' | 'editor'; + }; + + type FocusChatInstanceTelemetryClassification = { + owner: 'meganrogge'; + comment: 'Track usage of the focus chat terminal action.'; + target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether focusing targeted an existing instance or opened a command URI.' }; + location: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Location of the terminal instance when focusing.' }; + }; + + let target: FocusChatInstanceTelemetryEvent['target'] = 'none'; + let location: FocusChatInstanceTelemetryEvent['location'] = 'panel'; + if (instance) { + target = 'instance'; + location = instance.target === TerminalLocation.Editor ? 'editor' : 'panel'; + } else if (this._terminalCommandUri) { + target = 'commandUri'; + } + this._telemetryService.publicLog2('terminal/chatFocusInstance', { target, location }); + + if (instance) { + this._terminalService.setActiveInstance(instance); + if (instance.target === TerminalLocation.Editor) { + this._terminalEditorService.openEditor(instance); + } else { + await this._terminalGroupService.showPanel(true); + } + this._terminalService.setActiveInstance(instance); + await instance.focusWhenReady(true); + const command = this._getResolvedCommand(instance); + if (command) { + instance.xterm?.markTracker.revealCommand(command); + } return; } + if (this._terminalCommandUri) { this._terminalService.openResource(this._terminalCommandUri); } } + public continueInBackground(): void { + const sessionId = this._terminalData.terminalToolSessionId; + if (sessionId) { + this._terminalChatService.continueInBackground(sessionId); + } + } + + public async toggleOutputFromAction(): Promise { + this._userToggledOutput = true; + + type ToggleChatTerminalOutputTelemetryEvent = { + previousExpanded: boolean; + }; + type ToggleChatTerminalOutputTelemetryClassification = { + owner: 'meganrogge'; + comment: 'Track usage of the toggle chat terminal output action.'; + previousExpanded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the terminal output was expanded before the toggle.' }; + }; + this._telemetryService.publicLog2('terminal/chatToggleOutput', { + previousExpanded: this._outputView.isExpanded + }); + + if (!this._outputView.isExpanded) { + await this._toggleOutput(true); + return; + } + await this._toggleOutput(false); + } + public async toggleOutputFromKeyboard(): Promise { this._userToggledOutput = true; if (!this._outputView.isExpanded) { @@ -951,15 +1036,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart await this._collapseOutputAndFocusInput(); } - private async _toggleOutputFromAction(): Promise { - this._userToggledOutput = true; - if (!this._outputView.isExpanded) { - await this._toggleOutput(true); - return; - } - await this._toggleOutput(false); - } - private async _collapseOutputAndFocusInput(): Promise { if (this._outputView.isExpanded) { await this._toggleOutput(false); @@ -1460,178 +1536,6 @@ class ChatTerminalToolOutputSection extends Disposable { } } -export class ToggleChatTerminalOutputAction extends Action implements IAction { - private _expanded = false; - - constructor( - private readonly _toggle: () => Promise, - @IKeybindingService private readonly _keybindingService: IKeybindingService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - ) { - super( - TerminalContribCommandId.ToggleChatTerminalOutput, - localize('showTerminalOutput', 'Show Output'), - ThemeIcon.asClassName(Codicon.chevronRight), - true, - ); - this._updateTooltip(); - } - - public override async run(): Promise { - type ToggleChatTerminalOutputTelemetryEvent = { - previousExpanded: boolean; - }; - - type ToggleChatTerminalOutputTelemetryClassification = { - owner: 'meganrogge'; - comment: 'Track usage of the toggle chat terminal output action.'; - previousExpanded: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether the terminal output was expanded before the toggle.' }; - }; - this._telemetryService.publicLog2('terminal/chatToggleOutput', { - previousExpanded: this._expanded - }); - await this._toggle(); - } - - public syncPresentation(expanded: boolean): void { - this._expanded = expanded; - this._updatePresentation(); - this._updateTooltip(); - } - - public refreshKeybindingTooltip(): void { - this._updateTooltip(); - } - - private _updatePresentation(): void { - if (this._expanded) { - this.label = localize('hideTerminalOutput', 'Hide Output'); - this.class = ThemeIcon.asClassName(Codicon.chevronDown); - } else { - this.label = localize('showTerminalOutput', 'Show Output'); - this.class = ThemeIcon.asClassName(Codicon.chevronRight); - } - } - - private _updateTooltip(): void { - this.tooltip = this._keybindingService.appendKeybinding(this.label, TerminalContribCommandId.FocusMostRecentChatTerminalOutput); - } -} - -export class FocusChatInstanceAction extends Action implements IAction { - constructor( - private _instance: ITerminalInstance | undefined, - private _command: ITerminalCommand | undefined, - private readonly _commandUri: URI | undefined, - private readonly _commandId: string | undefined, - isTerminalHidden: boolean, - @ITerminalService private readonly _terminalService: ITerminalService, - @ITerminalEditorService private readonly _terminalEditorService: ITerminalEditorService, - @ITerminalGroupService private readonly _terminalGroupService: ITerminalGroupService, - @IKeybindingService private readonly _keybindingService: IKeybindingService, - @ITelemetryService private readonly _telemetryService: ITelemetryService, - ) { - super( - TerminalContribCommandId.FocusChatInstanceAction, - isTerminalHidden ? localize('showTerminal', 'Show and Focus Terminal') : localize('focusTerminal', 'Focus Terminal'), - ThemeIcon.asClassName(Codicon.openInProduct), - true, - ); - this._updateTooltip(); - } - - public override async run() { - this.label = this._instance?.shellLaunchConfig.hideFromUser ? localize('showAndFocusTerminal', 'Show and Focus Terminal') : localize('focusTerminal', 'Focus Terminal'); - this._updateTooltip(); - - let target: FocusChatInstanceTelemetryEvent['target'] = 'none'; - let location: FocusChatInstanceTelemetryEvent['location'] = 'panel'; - if (this._instance) { - target = 'instance'; - location = this._instance.target === TerminalLocation.Editor ? 'editor' : 'panel'; - } else if (this._commandUri) { - target = 'commandUri'; - } - - type FocusChatInstanceTelemetryEvent = { - target: 'instance' | 'commandUri' | 'none'; - location: 'panel' | 'editor'; - }; - - type FocusChatInstanceTelemetryClassification = { - owner: 'meganrogge'; - comment: 'Track usage of the focus chat terminal action.'; - target: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Whether focusing targeted an existing instance or opened a command URI.' }; - location: { classification: 'SystemMetaData'; purpose: 'FeatureInsight'; comment: 'Location of the terminal instance when focusing.' }; - }; - this._telemetryService.publicLog2('terminal/chatFocusInstance', { - target, - location - }); - - if (this._instance) { - this._terminalService.setActiveInstance(this._instance); - if (this._instance.target === TerminalLocation.Editor) { - this._terminalEditorService.openEditor(this._instance); - } else { - await this._terminalGroupService.showPanel(true); - } - this._terminalService.setActiveInstance(this._instance); - await this._instance.focusWhenReady(true); - const command = this._resolveCommand(); - if (command) { - this._instance.xterm?.markTracker.revealCommand(command); - } - return; - } - - if (this._commandUri) { - this._terminalService.openResource(this._commandUri); - } - } - - public refreshKeybindingTooltip(): void { - this._updateTooltip(); - } - - private _resolveCommand(): ITerminalCommand | undefined { - if (this._command && !this._command.endMarker?.isDisposed) { - return this._command; - } - if (!this._instance || !this._commandId) { - return this._command; - } - const commandDetection = this._instance.capabilities.get(TerminalCapability.CommandDetection); - const resolved = commandDetection?.commands.find(c => c.id === this._commandId); - if (resolved) { - this._command = resolved; - } - return this._command; - } - - private _updateTooltip(): void { - this.tooltip = this._keybindingService.appendKeybinding(this.label, TerminalContribCommandId.FocusMostRecentChatTerminal); - } -} - -export class ContinueInBackgroundAction extends Action implements IAction { - constructor( - private readonly _terminalToolSessionId: string, - @ITerminalChatService private readonly _terminalChatService: ITerminalChatService, - ) { - super( - TerminalContribCommandId.ContinueInBackground, - localize('continueInBackground', 'Continue in Background'), - ThemeIcon.asClassName(Codicon.debugContinue), - true, - ); - } - - public override async run(): Promise { - this._terminalChatService.continueInBackground(this._terminalToolSessionId); - } -} - export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart { private readonly _terminalContentElement: HTMLElement; private readonly _commandText: string; diff --git a/src/vs/workbench/contrib/terminal/browser/terminal.ts b/src/vs/workbench/contrib/terminal/browser/terminal.ts index 8bfd7b0d910..d9c9c6f1adf 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminal.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminal.ts @@ -111,6 +111,8 @@ export interface IChatTerminalToolProgressPart { readonly contentIndex: number; focusTerminal(): Promise; toggleOutputFromKeyboard(): Promise; + toggleOutputFromAction(): Promise; + continueInBackground(): void; focusOutput(): void; getCommandAndOutputAsText(): string | undefined; } diff --git a/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts b/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts new file mode 100644 index 00000000000..9bdffd0cfea --- /dev/null +++ b/src/vs/workbench/contrib/terminal/terminalContribChatExports.ts @@ -0,0 +1,10 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// HACK: Export some chat-specific symbols from `terminalContrib/` that are depended upon elsewhere. +// These are soft layer breakers between `terminal/` and `terminalContrib/` but there are +// difficulties in removing the dependency. These are explicitly defined here to avoid an eslint +// line override. +export { MENU_CHAT_TERMINAL_TOOL_PROGRESS, TerminalChatContextKeys } from '../terminalContrib/chat/browser/terminalChat.js'; diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts index bb5f4323288..c7d1dbf0658 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChat.ts @@ -31,6 +31,7 @@ export const enum TerminalChatCommandId { export const MENU_TERMINAL_CHAT_WIDGET_INPUT_SIDE_TOOLBAR = MenuId.for('terminalChatWidget'); export const MENU_TERMINAL_CHAT_WIDGET_STATUS = MenuId.for('terminalChatWidget.status'); export const MENU_TERMINAL_CHAT_WIDGET_TOOLBAR = MenuId.for('terminalChatWidget.toolbar'); +export const MENU_CHAT_TERMINAL_TOOL_PROGRESS = MenuId.for('chatTerminalToolProgress'); export const enum TerminalChatContextKeyStrings { ChatFocus = 'terminalChatFocus', @@ -45,6 +46,12 @@ export const enum TerminalChatContextKeyStrings { ChatSessionResponseVote = 'terminalChatSessionResponseVote', ChatHasTerminals = 'hasChatTerminals', ChatHasHiddenTerminals = 'hasHiddenChatTerminals', + ChatToolHasInstance = 'chatTerminalToolHasInstance', + ChatToolCanContinueInBackground = 'chatTerminalToolCanContinueInBackground', + ChatToolHasOutput = 'chatTerminalToolHasOutput', + ChatToolUsesCollapsible = 'chatTerminalToolUsesCollapsible', + ChatToolIsHiddenTerminal = 'chatTerminalToolIsHiddenTerminal', + ChatToolOutputExpanded = 'chatTerminalToolOutputExpanded', } @@ -76,4 +83,22 @@ export namespace TerminalChatContextKeys { /** Has hidden chat terminals */ export const hasHiddenChatTerminals = new RawContextKey(TerminalChatContextKeyStrings.ChatHasHiddenTerminals, false, localize('terminalHasHiddenChatTerminals', "Whether there are any hidden chat terminals.")); + + /** Whether the per-instance terminal tool progress part has a terminal instance attached */ + export const chatToolHasInstance = new RawContextKey(TerminalChatContextKeyStrings.ChatToolHasInstance, false); + + /** Whether the continue-in-background action is available */ + export const chatToolCanContinueInBackground = new RawContextKey(TerminalChatContextKeyStrings.ChatToolCanContinueInBackground, false); + + /** Whether terminal output is available for display */ + export const chatToolHasOutput = new RawContextKey(TerminalChatContextKeyStrings.ChatToolHasOutput, false); + + /** Whether the terminal tool uses a collapsible wrapper */ + export const chatToolUsesCollapsible = new RawContextKey(TerminalChatContextKeyStrings.ChatToolUsesCollapsible, false); + + /** Whether the associated terminal is hidden from the user */ + export const chatToolIsHiddenTerminal = new RawContextKey(TerminalChatContextKeyStrings.ChatToolIsHiddenTerminal, false); + + /** Whether the terminal output section is currently expanded */ + export const chatToolOutputExpanded = new RawContextKey(TerminalChatContextKeyStrings.ChatToolOutputExpanded, false); } diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts index dba3e6271f2..5d92d6d8211 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from '../../../../../base/common/event.js'; -import { Disposable, DisposableMap, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; +import { Disposable, DisposableMap, DisposableStore, IDisposable, toDisposable } from '../../../../../base/common/lifecycle.js'; import { ResourceMap } from '../../../../../base/common/map.js'; import { URI } from '../../../../../base/common/uri.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -86,6 +86,9 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ this._sessionAutoApprovalEnabled.delete(resource); } })); + + // Update context keys when terminal instances change (registered once, not per-registration) + this._register(this._terminalService.onDidChangeInstances(() => this._updateHasToolTerminalContextKeys())); } registerTerminalInstanceWithToolSession(terminalToolSessionId: string | undefined, instance: ITerminalInstance): void { @@ -96,15 +99,15 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ this._terminalInstancesByToolSessionId.set(terminalToolSessionId, instance); this._toolSessionIdByTerminalInstance.set(instance, terminalToolSessionId); this._onDidRegisterTerminalInstanceForToolSession.fire(instance); - this._terminalInstanceListenersByToolSessionId.set(terminalToolSessionId, instance.onDisposed(() => { + const instanceStore = new DisposableStore(); + instanceStore.add(instance.onDisposed(() => { this._terminalInstancesByToolSessionId.delete(terminalToolSessionId); this._toolSessionIdByTerminalInstance.delete(instance); this._terminalInstanceListenersByToolSessionId.deleteAndDispose(terminalToolSessionId); this._persistToStorage(); this._updateHasToolTerminalContextKeys(); })); - - this._register(this._chatService.onDidDisposeSession(e => { + instanceStore.add(this._chatService.onDidDisposeSession(e => { for (const resource of e.sessionResources) { if (LocalChatSessionUri.parseLocalSessionId(resource) === terminalToolSessionId) { this._terminalInstancesByToolSessionId.delete(terminalToolSessionId); @@ -117,9 +120,7 @@ export class TerminalChatService extends Disposable implements ITerminalChatServ } } })); - - // Update context keys when terminal instances change (including when terminals are created, disposed, revealed, or hidden) - this._register(this._terminalService.onDidChangeInstances(() => this._updateHasToolTerminalContextKeys())); + this._terminalInstanceListenersByToolSessionId.set(terminalToolSessionId, instanceStore); if (isNumber(instance.shellLaunchConfig?.attachPersistentProcess?.id) || isNumber(instance.persistentProcessId)) { this._persistToStorage(); diff --git a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts index 6bbefcb1ac4..268b65c54e0 100644 --- a/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts +++ b/src/vs/workbench/contrib/terminalContrib/chatAgentTools/test/electron-browser/runInTerminalTool.test.ts @@ -145,6 +145,14 @@ suite('RunInTerminalTool', () => { onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, } as IAgentSessionsService['model'] }); + instantiationService.stub(ITerminalService, { + createTerminal: async () => createdTerminalInstance, + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + onDidChangeInstances: Event.None, + revealTerminal: async () => { }, + setActiveInstance: () => { }, + setNextCommandId: async () => { } + }); instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); instantiationService.stub(IWorkspaceContextService, workspaceContextService); instantiationService.stub(IHistoryService, { @@ -182,13 +190,6 @@ suite('RunInTerminalTool', () => { return []; }, }); - instantiationService.stub(ITerminalService, { - createTerminal: async () => createdTerminalInstance, - onDidDisposeInstance: terminalServiceDisposeEmitter.event, - revealTerminal: async () => { }, - setActiveInstance: () => { }, - setNextCommandId: async () => { } - }); instantiationService.stub(ITerminalProfileResolverService, { getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) }); @@ -2075,6 +2076,12 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { onDidChangeSessionArchivedState: chatSessionArchivedEmitter.event, } as IAgentSessionsService['model'] }); + const terminalInstancesChangedEmitter = store.add(new Emitter()); + instantiationService.stub(ITerminalService, { + onDidDisposeInstance: terminalServiceDisposeEmitter.event, + onDidChangeInstances: terminalInstancesChangedEmitter.event, + setNextCommandId: async () => { } + }); instantiationService.stub(ITerminalChatService, store.add(instantiationService.createInstance(TerminalChatService))); instantiationService.stub(IHistoryService, { getLastActiveWorkspaceRoot: () => undefined @@ -2102,10 +2109,6 @@ suite('ChatAgentToolsContribution - tool registration refresh', () => { treeSitterLibraryService.isTest = true; instantiationService.stub(ITreeSitterLibraryService, treeSitterLibraryService); - instantiationService.stub(ITerminalService, { - onDidDisposeInstance: terminalServiceDisposeEmitter.event, - setNextCommandId: async () => { } - }); instantiationService.stub(ITerminalProfileResolverService, { getDefaultProfile: async () => ({ path: 'bash' } as ITerminalProfile) });