Path: blob/main/src/vs/workbench/contrib/chat/browser/accessibility/chatResponseAccessibleView.ts
5267 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { renderAsPlaintext } from '../../../../../base/browser/markdownRenderer.js';6import { Emitter, Event } from '../../../../../base/common/event.js';7import { IMarkdownString, isMarkdownString } from '../../../../../base/common/htmlContent.js';8import { stripIcons } from '../../../../../base/common/iconLabels.js';9import { Disposable, DisposableStore } from '../../../../../base/common/lifecycle.js';10import { URI } from '../../../../../base/common/uri.js';11import { localize } from '../../../../../nls.js';12import { AccessibleViewProviderId, AccessibleViewType, IAccessibleViewContentProvider } from '../../../../../platform/accessibility/browser/accessibleView.js';13import { IAccessibleViewImplementation } from '../../../../../platform/accessibility/browser/accessibleViewRegistry.js';14import { ServicesAccessor } from '../../../../../platform/instantiation/common/instantiation.js';15import { AccessibilityVerbositySettingId } from '../../../accessibility/browser/accessibilityConfiguration.js';16import { migrateLegacyTerminalToolSpecificData } from '../../common/chat.js';17import { ChatContextKeys } from '../../common/actions/chatContextKeys.js';18import { IChatExtensionsContent, IChatPullRequestContent, IChatSimpleToolInvocationData, IChatSubagentToolInvocationData, IChatTerminalToolInvocationData, IChatTodoListContent, IChatToolInputInvocationData, IChatToolInvocation, IChatToolResourcesInvocationData, ILegacyChatTerminalToolInvocationData, IToolResultOutputDetailsSerialized, isLegacyChatTerminalToolInvocationData } from '../../common/chatService/chatService.js';19import { isResponseVM } from '../../common/model/chatViewModel.js';20import { IToolResultInputOutputDetails, IToolResultOutputDetails, isToolResultInputOutputDetails, isToolResultOutputDetails, toolContentToA11yString } from '../../common/tools/languageModelToolsService.js';21import { ChatTreeItem, IChatWidget, IChatWidgetService } from '../chat.js';22import { Location } from '../../../../../editor/common/languages.js';2324export class ChatResponseAccessibleView implements IAccessibleViewImplementation {25readonly priority = 100;26readonly name = 'panelChat';27readonly type = AccessibleViewType.View;28readonly when = ChatContextKeys.inChatSession;29getProvider(accessor: ServicesAccessor) {30const widgetService = accessor.get(IChatWidgetService);31const widget = widgetService.lastFocusedWidget;32if (!widget) {33return;34}35const chatInputFocused = widget.hasInputFocus();36if (chatInputFocused) {37widget.focusResponseItem();38}3940const verifiedWidget: IChatWidget = widget;41const focusedItem = verifiedWidget.getFocus();42if (!focusedItem) {43return;44}4546return new ChatResponseAccessibleProvider(verifiedWidget, focusedItem, chatInputFocused);47}48}4950type ToolSpecificData = IChatTerminalToolInvocationData | ILegacyChatTerminalToolInvocationData | IChatToolInputInvocationData | IChatExtensionsContent | IChatPullRequestContent | IChatTodoListContent | IChatSubagentToolInvocationData | IChatSimpleToolInvocationData | IChatToolResourcesInvocationData;51type ResultDetails = Array<URI | Location> | IToolResultInputOutputDetails | IToolResultOutputDetails | IToolResultOutputDetailsSerialized;5253function isOutputDetailsSerialized(obj: unknown): obj is IToolResultOutputDetailsSerialized {54return typeof obj === 'object' && obj !== null && 'output' in obj &&55typeof (obj as IToolResultOutputDetailsSerialized).output === 'object' &&56(obj as IToolResultOutputDetailsSerialized).output?.type === 'data' &&57typeof (obj as IToolResultOutputDetailsSerialized).output?.base64Data === 'string';58}5960export function getToolSpecificDataDescription(toolSpecificData: ToolSpecificData | undefined): string {61if (!toolSpecificData) {62return '';63}6465if (isLegacyChatTerminalToolInvocationData(toolSpecificData) || toolSpecificData.kind === 'terminal') {66const terminalData = migrateLegacyTerminalToolSpecificData(toolSpecificData);67return terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original;68}6970switch (toolSpecificData.kind) {71case 'subagent': {72const parts: string[] = [];73if (toolSpecificData.agentName) {74parts.push(localize('subagentName', "Agent: {0}", toolSpecificData.agentName));75}76if (toolSpecificData.description) {77parts.push(toolSpecificData.description);78}79if (toolSpecificData.prompt) {80parts.push(localize('subagentPrompt', "Task: {0}", toolSpecificData.prompt));81}82return parts.join('. ') || '';83}84case 'extensions':85return toolSpecificData.extensions.length > 086? localize('extensionsList', "Extensions: {0}", toolSpecificData.extensions.join(', '))87: '';88case 'todoList': {89const todos = toolSpecificData.todoList;90if (todos.length === 0) {91return '';92}93const todoDescriptions = todos.map(t =>94localize('todoItem', "{0} ({1})", t.title, t.status)95);96return localize('todoListCount', "{0} items: {1}", todos.length, todoDescriptions.join('; '));97}98case 'pullRequest':99return localize('pullRequestInfo', "PR: {0} by {1}", toolSpecificData.title, toolSpecificData.author);100case 'input':101return typeof toolSpecificData.rawInput === 'string'102? toolSpecificData.rawInput103: JSON.stringify(toolSpecificData.rawInput);104case 'resources': {105const values = toolSpecificData.values;106if (values.length === 0) {107return '';108}109const paths = values.map(v => {110if ('uri' in v && 'range' in v) {111// Location112return `${v.uri.fsPath || v.uri.path}:${v.range.startLineNumber}`;113} else {114// URI115return v.fsPath || v.path;116}117}).join(', ');118return localize('resourcesList', "Resources: {0}", paths);119}120case 'simpleToolInvocation': {121const inputText = toolSpecificData.input;122const outputText = toolSpecificData.output;123return localize('simpleToolInvocation', "Input: {0}, Output: {1}", inputText, outputText);124}125default:126return '';127}128}129130export function getResultDetailsDescription(resultDetails: ResultDetails | undefined): { input?: string; files?: string[]; isError?: boolean } {131if (!resultDetails) {132return {};133}134135if (Array.isArray(resultDetails)) {136const files = resultDetails.map(ref => {137if (URI.isUri(ref)) {138return ref.fsPath || ref.path;139}140return ref.uri.fsPath || ref.uri.path;141});142return { files };143}144145if (isToolResultInputOutputDetails(resultDetails)) {146return {147input: resultDetails.input,148isError: resultDetails.isError149};150}151152if (isOutputDetailsSerialized(resultDetails)) {153return {154input: localize('binaryOutput', "{0} data", resultDetails.output.mimeType)155};156}157158if (isToolResultOutputDetails(resultDetails)) {159return {160input: localize('binaryOutput', "{0} data", resultDetails.output.mimeType)161};162}163164return {};165}166167export function getToolInvocationA11yDescription(168invocationMessage: string | undefined,169pastTenseMessage: string | undefined,170toolSpecificData: ToolSpecificData | undefined,171resultDetails: ResultDetails | undefined,172isComplete: boolean173): string {174const parts: string[] = [];175176const message = isComplete && pastTenseMessage ? pastTenseMessage : invocationMessage;177if (message) {178parts.push(message);179}180181const toolDataDesc = getToolSpecificDataDescription(toolSpecificData);182if (toolDataDesc) {183parts.push(toolDataDesc);184}185186if (isComplete && resultDetails) {187const details = getResultDetailsDescription(resultDetails);188if (details.isError) {189parts.unshift(localize('errored', "Errored"));190}191if (details.input && !toolDataDesc) {192parts.push(localize('input', "Input: {0}", details.input));193}194if (details.files && details.files.length > 0) {195parts.push(localize('files', "Files: {0}", details.files.join(', ')));196}197}198199return parts.join('. ');200}201202class ChatResponseAccessibleProvider extends Disposable implements IAccessibleViewContentProvider {203private _focusedItem!: ChatTreeItem;204private readonly _focusedItemDisposables = this._register(new DisposableStore());205private readonly _onDidChangeContent = this._register(new Emitter<void>());206readonly onDidChangeContent: Event<void> = this._onDidChangeContent.event;207constructor(208private readonly _widget: IChatWidget,209item: ChatTreeItem,210private readonly _wasOpenedFromInput: boolean211) {212super();213this._setFocusedItem(item);214}215216readonly id = AccessibleViewProviderId.PanelChat;217readonly verbositySettingKey = AccessibilityVerbositySettingId.Chat;218readonly options = { type: AccessibleViewType.View };219220provideContent(): string {221return this._getContent(this._focusedItem);222}223224private _setFocusedItem(item: ChatTreeItem): void {225this._focusedItem = item;226this._focusedItemDisposables.clear();227if (isResponseVM(item)) {228this._focusedItemDisposables.add(item.model.onDidChange(() => this._onDidChangeContent.fire()));229}230}231232private _renderMessageAsPlaintext(message: string | IMarkdownString): string {233return typeof message === 'string' ? message : stripIcons(renderAsPlaintext(message, { useLinkFormatter: true }));234}235236private _getContent(item: ChatTreeItem): string {237const contentParts: string[] = [];238239if (!isResponseVM(item)) {240return '';241}242243if ('errorDetails' in item && item.errorDetails) {244contentParts.push(item.errorDetails.message);245}246247// Process all parts in order to maintain the natural flow248for (const part of item.response.value) {249switch (part.kind) {250case 'thinking': {251const thinkingValue = Array.isArray(part.value) ? part.value.join('') : (part.value || '');252const trimmed = thinkingValue.trim();253if (trimmed) {254contentParts.push(localize('thinkingContent', "Thinking: {0}", trimmed));255}256break;257}258case 'markdownContent': {259const text = renderAsPlaintext(part.content, { includeCodeBlocksFences: true, useLinkFormatter: true });260if (text.trim()) {261contentParts.push(text);262}263break;264}265case 'elicitation2':266case 'elicitationSerialized': {267const title = part.title;268let elicitationContent = '';269if (typeof title === 'string') {270elicitationContent += `${title}\n`;271} else if (isMarkdownString(title)) {272elicitationContent += renderAsPlaintext(title, { includeCodeBlocksFences: true }) + '\n';273}274const message = part.message;275if (isMarkdownString(message)) {276elicitationContent += renderAsPlaintext(message, { includeCodeBlocksFences: true });277} else {278elicitationContent += message;279}280if (elicitationContent.trim()) {281contentParts.push(elicitationContent);282}283break;284}285case 'toolInvocation': {286const state = part.state.get();287if (state.type === IChatToolInvocation.StateKind.WaitingForConfirmation && state.confirmationMessages?.title) {288const title = this._renderMessageAsPlaintext(state.confirmationMessages.title);289const message = state.confirmationMessages.message ? this._renderMessageAsPlaintext(state.confirmationMessages.message) : '';290const toolDataDesc = getToolSpecificDataDescription(part.toolSpecificData);291let toolContent = title;292if (toolDataDesc) {293toolContent += `: ${toolDataDesc}`;294}295if (message) {296toolContent += `\n${message}`;297}298contentParts.push(toolContent);299} else if (state.type === IChatToolInvocation.StateKind.WaitingForPostApproval) {300const postApprovalDetails = isToolResultInputOutputDetails(state.resultDetails)301? state.resultDetails.input302: isToolResultOutputDetails(state.resultDetails)303? undefined304: toolContentToA11yString(state.contentForModel);305contentParts.push(localize('toolPostApprovalA11yView', "Approve results of {0}? Result: ", part.toolId) + (postApprovalDetails ?? ''));306} else {307const resultDetails = IChatToolInvocation.resultDetails(part);308const isComplete = IChatToolInvocation.isComplete(part);309const description = getToolInvocationA11yDescription(310this._renderMessageAsPlaintext(part.invocationMessage),311part.pastTenseMessage ? this._renderMessageAsPlaintext(part.pastTenseMessage) : undefined,312part.toolSpecificData,313resultDetails,314isComplete315);316if (description) {317contentParts.push(description);318}319}320break;321}322case 'toolInvocationSerialized': {323const description = getToolInvocationA11yDescription(324this._renderMessageAsPlaintext(part.invocationMessage),325part.pastTenseMessage ? this._renderMessageAsPlaintext(part.pastTenseMessage) : undefined,326part.toolSpecificData,327part.resultDetails,328part.isComplete329);330if (description) {331contentParts.push(description);332}333break;334}335}336}337338return this._normalizeWhitespace(contentParts.join('\n'));339}340341private _normalizeWhitespace(content: string): string {342const lines = content.split(/\r?\n/);343const normalized: string[] = [];344for (const line of lines) {345if (line.trim().length === 0) {346continue;347}348normalized.push(line);349}350return normalized.join('\n');351}352353onClose(): void {354this._widget.reveal(this._focusedItem);355if (this._wasOpenedFromInput) {356this._widget.focusInput();357} else {358this._widget.focus(this._focusedItem);359}360}361362provideNextContent(): string | undefined {363const next = this._widget.getSibling(this._focusedItem, 'next');364if (next) {365this._setFocusedItem(next);366return this._getContent(next);367}368return;369}370371providePreviousContent(): string | undefined {372const previous = this._widget.getSibling(this._focusedItem, 'previous');373if (previous) {374this._setFocusedItem(previous);375return this._getContent(previous);376}377return;378}379}380381382