Skip to content

Commit cdf1455

Browse files
authored
make terminal output feature accessible to screen reader users (#276211)
1 parent fb78908 commit cdf1455

File tree

8 files changed

+133
-5
lines changed

8 files changed

+133
-5
lines changed

src/vs/platform/accessibility/browser/accessibleView.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export const enum AccessibleViewProviderId {
2020
DiffEditor = 'diffEditor',
2121
MergeEditor = 'mergeEditor',
2222
PanelChat = 'panelChat',
23+
ChatTerminalOutput = 'chatTerminalOutput',
2324
InlineChat = 'inlineChat',
2425
AgentChat = 'agentChat',
2526
QuickChat = 'quickChat',

src/vs/workbench/contrib/accessibility/browser/accessibilityConfiguration.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@ export const enum AccessibilityVerbositySettingId {
5151
MergeEditor = 'accessibility.verbosity.mergeEditor',
5252
Chat = 'accessibility.verbosity.panelChat',
5353
InlineChat = 'accessibility.verbosity.inlineChat',
54-
TerminalChat = 'accessibility.verbosity.terminalChat',
54+
TerminalInlineChat = 'accessibility.verbosity.terminalChat',
55+
TerminalChatOutput = 'accessibility.verbosity.terminalChatOutput',
5556
InlineCompletions = 'accessibility.verbosity.inlineCompletions',
5657
KeybindingsEditor = 'accessibility.verbosity.keybindingsEditor',
5758
Notebook = 'accessibility.verbosity.notebook',
@@ -141,6 +142,10 @@ const configuration: IConfigurationNode = {
141142
description: localize('verbosity.interactiveEditor.description', 'Provide information about how to access the inline editor chat accessibility help menu and alert with hints that describe how to use the feature when the input is focused.'),
142143
...baseVerbosityProperty
143144
},
145+
[AccessibilityVerbositySettingId.TerminalChatOutput]: {
146+
description: localize('verbosity.terminalChatOutput.description', 'Provide information about how to open the chat terminal output in the Accessible View.'),
147+
...baseVerbosityProperty
148+
},
144149
[AccessibilityVerbositySettingId.InlineCompletions]: {
145150
description: localize('verbosity.inlineCompletions.description', 'Provide information about how to access the inline completions hover and Accessible View.'),
146151
...baseVerbosityProperty

src/vs/workbench/contrib/chat/browser/chat.contribution.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ import { ChatCompatibilityNotifier, ChatExtensionPointHandler } from './chatPart
108108
import { ChatPasteProvidersFeature } from './chatPasteProviders.js';
109109
import { QuickChatService } from './chatQuick.js';
110110
import { ChatResponseAccessibleView } from './chatResponseAccessibleView.js';
111+
import { ChatTerminalOutputAccessibleView } from './chatTerminalOutputAccessibleView.js';
111112
import { LocalChatSessionsProvider } from './chatSessions/localChatSessionsProvider.js';
112113
import { ChatSessionsView, ChatSessionsViewContrib } from './chatSessions/view/chatSessionsView.js';
113114
import { ChatSetupContribution, ChatTeardownContribution } from './chatSetup.js';
@@ -870,6 +871,7 @@ class ChatAgentSettingContribution extends Disposable implements IWorkbenchContr
870871
}
871872
}
872873

874+
AccessibleViewRegistry.register(new ChatTerminalOutputAccessibleView());
873875
AccessibleViewRegistry.register(new ChatResponseAccessibleView());
874876
AccessibleViewRegistry.register(new PanelChatAccessibilityHelp());
875877
AccessibleViewRegistry.register(new QuickChatAccessibilityHelp());

src/vs/workbench/contrib/chat/browser/chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.ts

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import * as domSanitize from '../../../../../../base/browser/domSanitize.js';
3939
import { DomSanitizerConfig } from '../../../../../../base/browser/domSanitize.js';
4040
import { allowedMarkdownHtmlAttributes } from '../../../../../../base/browser/markdownRenderer.js';
4141
import { stripIcons } from '../../../../../../base/common/iconLabels.js';
42+
import { IAccessibleViewService } from '../../../../../../platform/accessibility/browser/accessibleView.js';
43+
import { IContextKey, IContextKeyService } from '../../../../../../platform/contextkey/common/contextkey.js';
44+
import { AccessibilityVerbositySettingId } from '../../../../accessibility/browser/accessibilityConfiguration.js';
45+
import { ChatContextKeys } from '../../../common/chatContextKeys.js';
4246
import { EditorPool } from '../chatContentCodePools.js';
4347

4448
const MAX_TERMINAL_OUTPUT_PREVIEW_HEIGHT = 200;
@@ -52,6 +56,8 @@ const sanitizerConfig = Object.freeze<DomSanitizerConfig>({
5256
}
5357
});
5458

59+
let lastFocusedProgressPart: ChatTerminalToolProgressPart | undefined;
60+
5561
export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart {
5662
public readonly domNode: HTMLElement;
5763

@@ -64,6 +70,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
6470
private _outputContent: HTMLElement | undefined;
6571
private _outputResizeObserver: ResizeObserver | undefined;
6672
private _renderedOutputHeight: number | undefined;
73+
private readonly _terminalOutputContextKey: IContextKey<boolean>;
74+
private readonly _outputAriaLabelBase: string;
75+
private readonly _displayCommand: string;
76+
private _lastOutputTruncated = false;
6777

6878
private readonly _showOutputAction = this._register(new MutableDisposable<ToggleChatTerminalOutputAction>());
6979
private _showOutputActionAdded = false;
@@ -89,6 +99,8 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
8999
@IInstantiationService private readonly _instantiationService: IInstantiationService,
90100
@ITerminalChatService private readonly _terminalChatService: ITerminalChatService,
91101
@ITerminalService private readonly _terminalService: ITerminalService,
102+
@IContextKeyService private readonly _contextKeyService: IContextKeyService,
103+
@IAccessibleViewService private readonly _accessibleViewService: IAccessibleViewService,
92104
) {
93105
super(toolInvocation);
94106

@@ -102,6 +114,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
102114
]);
103115

104116
const command = terminalData.commandLine.userEdited ?? terminalData.commandLine.toolEdited ?? terminalData.commandLine.original;
117+
this._displayCommand = stripIcons(command);
118+
this._terminalOutputContextKey = ChatContextKeys.inChatTerminalToolOutput.bindTo(this._contextKeyService);
119+
this._outputAriaLabelBase = localize('chatTerminalOutputAriaLabel', 'Terminal output for {0}', this._displayCommand);
105120

106121
this._titlePart = elements.title;
107122
const titlePart = this._register(_instantiationService.createInstance(
@@ -161,6 +176,9 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
161176
this._outputContainer = elements.output;
162177
this._outputContainer.classList.add('collapsed');
163178
this._outputBody = dom.$('.chat-terminal-output-body');
179+
this._register(dom.addDisposableListener(this._outputContainer, dom.EventType.FOCUS_IN, () => this._handleOutputFocus()));
180+
this._register(dom.addDisposableListener(this._outputContainer, dom.EventType.FOCUS_OUT, e => this._handleOutputBlur(e as FocusEvent)));
181+
this._register(toDisposable(() => this._handleDispose()));
164182

165183
const progressPart = this._register(_instantiationService.createInstance(ChatProgressSubPart, elements.container, this.getIcon(), terminalData.autoApproveInfo));
166184
this.domNode = progressPart.domNode;
@@ -388,6 +406,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
388406
} else {
389407
this._ensureOutputResizeObserver();
390408
}
409+
this._updateOutputAriaLabel();
391410

392411
return true;
393412
}
@@ -447,6 +466,63 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
447466
}));
448467
}
449468

469+
private _handleOutputFocus(): void {
470+
this._terminalOutputContextKey.set(true);
471+
lastFocusedProgressPart = this;
472+
this._updateOutputAriaLabel();
473+
}
474+
475+
private _handleOutputBlur(event: FocusEvent): void {
476+
const nextTarget = event.relatedTarget as HTMLElement | null;
477+
if (nextTarget && this._outputContainer.contains(nextTarget)) {
478+
return;
479+
}
480+
this._terminalOutputContextKey.reset();
481+
this._clearLastFocusedPart();
482+
}
483+
484+
private _handleDispose(): void {
485+
this._terminalOutputContextKey.reset();
486+
this._clearLastFocusedPart();
487+
}
488+
489+
private _clearLastFocusedPart(): void {
490+
if (lastFocusedProgressPart === this) {
491+
lastFocusedProgressPart = undefined;
492+
}
493+
}
494+
495+
private _updateOutputAriaLabel(): void {
496+
if (!this._outputScrollbar) {
497+
return;
498+
}
499+
const scrollableDomNode = this._outputScrollbar.getDomNode();
500+
scrollableDomNode.setAttribute('role', 'region');
501+
const accessibleViewHint = this._accessibleViewService.getOpenAriaHint(AccessibilityVerbositySettingId.TerminalChatOutput);
502+
const label = accessibleViewHint
503+
? this._outputAriaLabelBase + ', ' + accessibleViewHint
504+
: this._outputAriaLabelBase;
505+
scrollableDomNode.setAttribute('aria-label', label);
506+
}
507+
508+
public getCommandAndOutputAsText(): string | undefined {
509+
const commandHeader = localize('chatTerminalOutputAccessibleViewHeader', 'Command: {0}', this._displayCommand);
510+
const command = this._getResolvedCommand();
511+
const output = command?.getOutput()?.trimEnd();
512+
if (!output) {
513+
return `${commandHeader}\n${localize('chat.terminalOutputEmpty', 'No output was produced by the command.')}`;
514+
}
515+
let result = `${commandHeader}\n${output}`;
516+
if (this._lastOutputTruncated) {
517+
result += `\n\n${localize('chat.terminalOutputTruncated', 'Output truncated to first {0} lines.', CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES)}`;
518+
}
519+
return result;
520+
}
521+
522+
public focusOutput(): void {
523+
this._outputScrollbar?.getDomNode().focus();
524+
}
525+
450526
private async _collectOutput(terminalInstance: ITerminalInstance | undefined): Promise<{ text: string; truncated: boolean } | undefined> {
451527
const commandDetection = terminalInstance?.capabilities.get(TerminalCapability.CommandDetection);
452528
const commands = commandDetection?.commands;
@@ -463,6 +539,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
463539
}
464540

465541
private _renderOutput(result: { text: string; truncated: boolean }): HTMLElement {
542+
this._lastOutputTruncated = result.truncated;
466543
const container = document.createElement('div');
467544
container.classList.add('chat-terminal-output-content');
468545

@@ -482,7 +559,7 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
482559
if (result.truncated) {
483560
const note = document.createElement('div');
484561
note.classList.add('chat-terminal-output-info');
485-
note.textContent = localize('chat.terminalOutputTruncated', 'Output truncated to first {0} characters.', CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES);
562+
note.textContent = localize('chat.terminalOutputTruncated', 'Output truncated to first {0} lines.', CHAT_TERMINAL_OUTPUT_MAX_PREVIEW_LINES);
486563
container.appendChild(note);
487564
}
488565

@@ -500,6 +577,10 @@ export class ChatTerminalToolProgressPart extends BaseChatToolInvocationSubPart
500577
}
501578
}
502579

580+
export function getFocusedTerminalToolProgressPart(): ChatTerminalToolProgressPart | undefined {
581+
return lastFocusedProgressPart;
582+
}
583+
503584
export const openTerminalSettingsLinkCommandId = '_chat.openTerminalSettingsLink';
504585

505586
CommandsRegistry.registerCommand(openTerminalSettingsLinkCommandId, async (accessor, scopeRaw: string) => {
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
import { AccessibleContentProvider, AccessibleViewProviderId, AccessibleViewType } from '../../../../platform/accessibility/browser/accessibleView.js';
7+
import { IAccessibleViewImplementation } from '../../../../platform/accessibility/browser/accessibleViewRegistry.js';
8+
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
9+
import { AccessibilityVerbositySettingId } from '../../accessibility/browser/accessibilityConfiguration.js';
10+
import { ChatContextKeys } from '../common/chatContextKeys.js';
11+
import { getFocusedTerminalToolProgressPart } from './chatContentParts/toolInvocationParts/chatTerminalToolProgressPart.js';
12+
13+
export class ChatTerminalOutputAccessibleView implements IAccessibleViewImplementation {
14+
readonly priority = 115;
15+
readonly name = 'chatTerminalOutput';
16+
readonly type = AccessibleViewType.View;
17+
readonly when = ChatContextKeys.inChatTerminalToolOutput;
18+
19+
getProvider(_accessor: ServicesAccessor) {
20+
const part = getFocusedTerminalToolProgressPart();
21+
if (!part) {
22+
return;
23+
}
24+
25+
const content = part.getCommandAndOutputAsText();
26+
if (!content) {
27+
return;
28+
}
29+
30+
return new AccessibleContentProvider(
31+
AccessibleViewProviderId.ChatTerminalOutput,
32+
{ type: AccessibleViewType.View, id: AccessibleViewProviderId.ChatTerminalOutput, language: 'text' },
33+
() => content,
34+
() => part.focusOutput(),
35+
AccessibilityVerbositySettingId.TerminalChatOutput
36+
);
37+
}
38+
}

src/vs/workbench/contrib/chat/common/chatContextKeys.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export namespace ChatContextKeys {
3333
export const inChatInput = new RawContextKey<boolean>('inChatInput', false, { type: 'boolean', description: localize('inInteractiveInput', "True when focus is in the chat input, false otherwise.") });
3434
export const inChatSession = new RawContextKey<boolean>('inChat', false, { type: 'boolean', description: localize('inChat', "True when focus is in the chat widget, false otherwise.") });
3535
export const inChatEditor = new RawContextKey<boolean>('inChatEditor', false, { type: 'boolean', description: localize('inChatEditor', "Whether focus is in a chat editor.") });
36+
export const inChatTerminalToolOutput = new RawContextKey<boolean>('inChatTerminalToolOutput', false, { type: 'boolean', description: localize('inChatTerminalToolOutput', "True when focus is in the chat terminal output region.") });
3637
export const hasPromptFile = new RawContextKey<boolean>('chatPromptFileAttached', false, { type: 'boolean', description: localize('chatPromptFileAttachedContextDescription', "True when the chat has a prompt file attached.") });
3738
export const chatModeKind = new RawContextKey<ChatModeKind>('chatAgentKind', ChatModeKind.Ask, { type: 'string', description: localize('agentKind', "The 'kind' of the current agent.") });
3839
export const chatToolCount = new RawContextKey<number>('chatToolCount', 0, { type: 'number', description: localize('chatToolCount', "The number of tools available in the current agent.") });

src/vs/workbench/contrib/terminalContrib/chat/browser/terminal.initialHint.contribution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ class TerminalInitialHintWidget extends Disposable {
225225
) {
226226
super();
227227
this._toDispose.add(_instance.onDidFocus(() => {
228-
if (this._instance.hasFocus && this._isVisible && this._ariaLabel && this._configurationService.getValue(AccessibilityVerbositySettingId.TerminalChat)) {
228+
if (this._instance.hasFocus && this._isVisible && this._ariaLabel && this._configurationService.getValue(AccessibilityVerbositySettingId.TerminalInlineChat)) {
229229
status(this._ariaLabel);
230230
}
231231
}));
@@ -323,7 +323,7 @@ class TerminalInitialHintWidget extends Disposable {
323323

324324
const { hintElement, ariaLabel } = this._getHintInlineChat(agents);
325325
this._domNode.append(hintElement);
326-
this._ariaLabel = ariaLabel.concat(localize('disableHint', ' Toggle {0} in settings to disable this hint.', AccessibilityVerbositySettingId.TerminalChat));
326+
this._ariaLabel = ariaLabel.concat(localize('disableHint', ' Toggle {0} in settings to disable this hint.', AccessibilityVerbositySettingId.TerminalInlineChat));
327327

328328
this._toDispose.add(dom.addDisposableListener(this._domNode, 'click', () => {
329329
this._domNode?.remove();

src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatAccessibilityHelp.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export class TerminalChatAccessibilityHelp implements IAccessibleViewImplementat
3232
{ type: AccessibleViewType.Help },
3333
() => helpText,
3434
() => TerminalChatController.get(instance)?.terminalChatWidget?.focus(),
35-
AccessibilityVerbositySettingId.TerminalChat,
35+
AccessibilityVerbositySettingId.TerminalInlineChat,
3636
);
3737
}
3838
}

0 commit comments

Comments
 (0)