move bg button registration (#308000)

fix #304828
This commit is contained in:
Megan Rogge
2026-04-06 13:21:32 -04:00
committed by GitHub
parent 3609a92bb0
commit 4e5ed5dba9
6 changed files with 280 additions and 335 deletions
@@ -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<IChatToolInvocation | IChatToolInvocationSerialized, boolean>();
// --- 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<boolean>;
@@ -257,14 +315,15 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
private readonly _contentIndex: number;
private readonly _sessionResource: URI;
private readonly _showOutputAction = this._register(new MutableDisposable<ToggleChatTerminalOutputAction>());
private _showOutputActionAdded = false;
private readonly _focusAction = this._register(new MutableDisposable<FocusChatInstanceAction>());
private readonly _continueInBackgroundAction = this._register(new MutableDisposable<ContinueInBackgroundAction>());
// Scoped context keys that drive toolbar action visibility
private readonly _hasInstanceKey: IContextKey<boolean>;
private readonly _canContinueInBackgroundKey: IContextKey<boolean>;
private readonly _hasOutputKey: IContextKey<boolean>;
private readonly _isHiddenTerminalKey: IContextKey<boolean>;
private readonly _outputExpandedKey: IContextKey<boolean>;
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<boolean>(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<boolean>(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<IDisposable>());
const tryResolveCommand = async (): Promise<ITerminalCommand | undefined> => {
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<void> {
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<FocusChatInstanceTelemetryEvent, FocusChatInstanceTelemetryClassification>('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<void> {
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<ToggleChatTerminalOutputTelemetryEvent, ToggleChatTerminalOutputTelemetryClassification>('terminal/chatToggleOutput', {
previousExpanded: this._outputView.isExpanded
});
if (!this._outputView.isExpanded) {
await this._toggleOutput(true);
return;
}
await this._toggleOutput(false);
}
public async toggleOutputFromKeyboard(): Promise<void> {
this._userToggledOutput = true;
if (!this._outputView.isExpanded) {
@@ -951,15 +1036,6 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
await this._collapseOutputAndFocusInput();
}
private async _toggleOutputFromAction(): Promise<void> {
this._userToggledOutput = true;
if (!this._outputView.isExpanded) {
await this._toggleOutput(true);
return;
}
await this._toggleOutput(false);
}
private async _collapseOutputAndFocusInput(): Promise<void> {
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<void>,
@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<void> {
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<ToggleChatTerminalOutputTelemetryEvent, ToggleChatTerminalOutputTelemetryClassification>('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<FocusChatInstanceTelemetryEvent, FocusChatInstanceTelemetryClassification>('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<void> {
this._terminalChatService.continueInBackground(this._terminalToolSessionId);
}
}
export class ChatTerminalThinkingCollapsibleWrapper extends ChatCollapsibleContentPart {
private readonly _terminalContentElement: HTMLElement;
private readonly _commandText: string;
@@ -111,6 +111,8 @@ export interface IChatTerminalToolProgressPart {
readonly contentIndex: number;
focusTerminal(): Promise<void>;
toggleOutputFromKeyboard(): Promise<void>;
toggleOutputFromAction(): Promise<void>;
continueInBackground(): void;
focusOutput(): void;
getCommandAndOutputAsText(): string | undefined;
}
@@ -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';
@@ -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<boolean>(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<boolean>(TerminalChatContextKeyStrings.ChatToolHasInstance, false);
/** Whether the continue-in-background action is available */
export const chatToolCanContinueInBackground = new RawContextKey<boolean>(TerminalChatContextKeyStrings.ChatToolCanContinueInBackground, false);
/** Whether terminal output is available for display */
export const chatToolHasOutput = new RawContextKey<boolean>(TerminalChatContextKeyStrings.ChatToolHasOutput, false);
/** Whether the terminal tool uses a collapsible wrapper */
export const chatToolUsesCollapsible = new RawContextKey<boolean>(TerminalChatContextKeyStrings.ChatToolUsesCollapsible, false);
/** Whether the associated terminal is hidden from the user */
export const chatToolIsHiddenTerminal = new RawContextKey<boolean>(TerminalChatContextKeyStrings.ChatToolIsHiddenTerminal, false);
/** Whether the terminal output section is currently expanded */
export const chatToolOutputExpanded = new RawContextKey<boolean>(TerminalChatContextKeyStrings.ChatToolOutputExpanded, false);
}
@@ -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();
@@ -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<void>());
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)
});