diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts index f0e329ba490..d9d262db5d5 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatAccessibilityHelp.ts @@ -58,23 +58,23 @@ export class AgentChatAccessibilityHelp implements IAccessibleViewImplementation } } -export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService): string { +export function getAccessibilityHelpText(type: 'panelChat' | 'inlineChat' | 'agentView' | 'quickChat' | 'editsView' | 'agentView', keybindingService: IKeybindingService): string { const content = []; - if (type === 'panelChat' || type === 'quickChat') { + if (type === 'panelChat' || type === 'quickChat' || type === 'agentView') { if (type === 'quickChat') { content.push(localize('chat.overview', 'The quick chat view is comprised of an input box and a request/response list. The input box is used to make requests and the list is used to display responses.')); content.push(localize('chat.differenceQuick', 'The quick chat view is a transient interface for making and viewing requests, while the panel chat view is a persistent interface that also supports navigating suggested follow-up questions.')); } if (type === 'panelChat') { content.push(localize('chat.differencePanel', 'The panel chat view is a persistent interface that also supports navigating suggested follow-up questions, while the quick chat view is a transient interface for making and viewing requests.')); - content.push(localize('chat.followUp', 'In the input box, navigate to the suggested follow up question (Shift+Tab) and press Enter to run it.')); } content.push(localize('chat.requestHistory', 'In the input box, use up and down arrows to navigate your request history. Edit input and use enter or the submit button to run a new request.')); content.push(localize('chat.inspectResponse', 'In the input box, inspect the last response in the accessible view{0}.', '')); + content.push(localize('workbench.action.chat.focus', 'To focus the chat request and response list, invoke the Focus Chat command{0}. This will move focus to the most recent response, which you can then navigate using the up and down arrow keys.', getChatFocusKeybindingLabel(keybindingService, type, 'last'))); + content.push(localize('workbench.action.chat.focusLastFocusedItem', 'To return to the last chat response you focused, invoke the Focus Last Focused Chat Response command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'lastFocused'))); + content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, 'input'))); content.push(localize('chat.progressVerbosity', 'As the chat request is being processed, you will hear verbose progress updates if the request takes more than 4 seconds. This includes information like searched text for with X results, created file , or read file . This can be disabled with accessibility.verboseChatProgressUpdates.')); content.push(localize('chat.announcement', 'Chat responses will be announced as they come in. A response will indicate the number of code blocks, if any, and then the rest of the response.')); - content.push(localize('workbench.action.chat.focus', 'To focus the chat request/response list, which can be navigated with up and down arrows, invoke the Focus Chat command{0}.', getChatFocusKeybindingLabel(keybindingService, type, false))); - content.push(localize('workbench.action.chat.focusInput', 'To focus the input box for chat requests, invoke the Focus Chat Input command{0}.', getChatFocusKeybindingLabel(keybindingService, type, true))); content.push(localize('workbench.action.chat.nextCodeBlock', 'To focus the next code block within a response, invoke the Chat: Next Code Block command{0}.', '')); content.push(localize('workbench.action.chat.announceConfirmation', 'To focus pending chat confirmation dialogs, invoke the Focus Chat Confirmation Status command{0}.', '')); if (type === 'panelChat') { @@ -166,11 +166,13 @@ export function getChatAccessibilityHelpProvider(accessor: ServicesAccessor, edi // The when clauses for actions may not be true when we invoke the accessible view, so we need to provide the keybinding label manually // to ensure it's correct -function getChatFocusKeybindingLabel(keybindingService: IKeybindingService, type: 'panelChat' | 'inlineChat' | 'quickChat', focusInput?: boolean): string | undefined { +function getChatFocusKeybindingLabel(keybindingService: IKeybindingService, type: 'agentView' | 'panelChat' | 'inlineChat' | 'quickChat', focus?: 'lastFocused' | 'last' | 'input'): string | undefined { let kbs; const fallback = ' (unassigned keybinding)'; - if (focusInput) { - kbs = keybindingService.lookupKeybindings('workbench.action.chat.focusInput'); + if (focus === 'input') { + kbs = keybindingService.lookupKeybindings('workbench.chat.action.focusInput'); + } else if (focus === 'lastFocused') { + kbs = keybindingService.lookupKeybindings('workbench.chat.action.focusLastFocused'); } else { kbs = keybindingService.lookupKeybindings('chat.action.focus'); } @@ -178,15 +180,15 @@ function getChatFocusKeybindingLabel(keybindingService: IKeybindingService, type return fallback; } let kb; - if (type === 'panelChat') { - if (focusInput) { + if (type === 'agentView' || type === 'panelChat') { + if (focus) { kb = kbs.find(kb => kb.getAriaLabel()?.includes('DownArrow'))?.getAriaLabel(); } else { kb = kbs.find(kb => kb.getAriaLabel()?.includes('UpArrow'))?.getAriaLabel(); } } else { // Quick chat - if (focusInput) { + if (focus) { kb = kbs.find(kb => kb.getAriaLabel()?.includes('UpArrow'))?.getAriaLabel(); } else { kb = kbs.find(kb => kb.getAriaLabel()?.includes('DownArrow'))?.getAriaLabel(); diff --git a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts index c54230c5cdc..3f429897935 100644 --- a/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts +++ b/src/vs/workbench/contrib/chat/browser/actions/chatActions.ts @@ -1329,7 +1329,45 @@ export function registerChatActions() { const editorUri = editor.getModel()?.uri; if (editorUri) { const widgetService = accessor.get(IChatWidgetService); - widgetService.getWidgetByInputUri(editorUri)?.focusLastMessage(); + widgetService.getWidgetByInputUri(editorUri)?.focusResponseItem(); + } + } + }); + + registerAction2(class FocusMostRecentlyFocusedChatAction extends EditorAction2 { + constructor() { + super({ + id: 'workbench.chat.action.focusLastFocused', + title: localize2('actions.interactiveSession.focusLastFocused', 'Focus Last Focused Chat List Item'), + precondition: ContextKeyExpr.and(ChatContextKeys.inChatInput), + category: CHAT_CATEGORY, + keybinding: [ + // On mac, require that the cursor is at the top of the input, to avoid stealing cmd+up to move the cursor to the top + { + when: ContextKeyExpr.and(ChatContextKeys.inputCursorAtTop, ChatContextKeys.inQuickChat.negate()), + primary: KeyMod.CtrlCmd | KeyCode.UpArrow | KeyMod.Shift, + weight: KeybindingWeight.EditorContrib + 1, + }, + // On win/linux, ctrl+up can always focus the chat list + { + when: ContextKeyExpr.and(ContextKeyExpr.or(IsWindowsContext, IsLinuxContext), ChatContextKeys.inQuickChat.negate()), + primary: KeyMod.CtrlCmd | KeyCode.UpArrow | KeyMod.Shift, + weight: KeybindingWeight.EditorContrib + 1, + }, + { + when: ContextKeyExpr.and(ChatContextKeys.inChatSession, ChatContextKeys.inQuickChat), + primary: KeyMod.CtrlCmd | KeyCode.DownArrow | KeyMod.Shift, + weight: KeybindingWeight.WorkbenchContrib + 1, + } + ] + }); + } + + runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void | Promise { + const editorUri = editor.getModel()?.uri; + if (editorUri) { + const widgetService = accessor.get(IChatWidgetService); + widgetService.getWidgetByInputUri(editorUri)?.focusResponseItem(true); } } }); diff --git a/src/vs/workbench/contrib/chat/browser/chat.ts b/src/vs/workbench/contrib/chat/browser/chat.ts index 27d0355f9e8..c733c58a0ce 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.ts @@ -237,7 +237,11 @@ export interface IChatWidget { rerunLastRequest(): Promise; setInputPlaceholder(placeholder: string): void; resetInputPlaceholder(): void; - focusLastMessage(): void; + /** + * Focuses the response item in the list. + * @param lastFocused Focuses the most recently focused response. Otherwise, focuses the last response. + */ + focusResponseItem(lastFocused?: boolean): void; focusInput(): void; hasInputFocus(): boolean; getModeRequestOptions(): Partial; diff --git a/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts index f82fb6e7779..bf04fbcdf75 100644 --- a/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts +++ b/src/vs/workbench/contrib/chat/browser/chatResponseAccessibleView.ts @@ -29,7 +29,7 @@ export class ChatResponseAccessibleView implements IAccessibleViewImplementation } const chatInputFocused = widget.hasInputFocus(); if (chatInputFocused) { - widget.focusLastMessage(); + widget.focusResponseItem(); } const verifiedWidget: IChatWidget = widget; diff --git a/src/vs/workbench/contrib/chat/browser/chatWidget.ts b/src/vs/workbench/contrib/chat/browser/chatWidget.ts index 18f2918f442..7cf6d1b3701 100644 --- a/src/vs/workbench/contrib/chat/browser/chatWidget.ts +++ b/src/vs/workbench/contrib/chat/browser/chatWidget.ts @@ -324,6 +324,8 @@ export class ChatWidget extends Disposable implements IChatWidget { // Cache for prompt file descriptions to avoid async calls during rendering private readonly promptDescriptionsCache = new Map(); + private _mostRecentlyFocusedItemIndex: number = -1; + private set viewModel(viewModel: ChatViewModel | undefined) { if (this._viewModel === viewModel) { return; @@ -1555,6 +1557,18 @@ export class ChatWidget extends Disposable implements IChatWidget { listInactiveSelectionIconForeground: undefined, } })); + + this._register(this.tree.onDidChangeFocus(() => { + const focused = this.tree.getFocus(); + if (focused && focused.length > 0) { + const focusedItem = focused[0]; + const items = this.tree.getNode(null).children; + const idx = items.findIndex(i => i.element === focusedItem); + if (idx !== -1) { + this._mostRecentlyFocusedItemIndex = idx; + } + } + })); this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); this._register(this.tree.onDidChangeContentHeight(() => { @@ -2073,6 +2087,7 @@ export class ChatWidget extends Disposable implements IChatWidget { return; } + this._mostRecentlyFocusedItemIndex = items.indexOf(node); this.tree.setFocus([node.element]); this.tree.domFocus(); } @@ -2379,18 +2394,22 @@ export class ChatWidget extends Disposable implements IChatWidget { return this.renderer.getLastFocusedFileTreeForResponse(response); } - focusLastMessage(): void { + focusResponseItem(lastFocused?: boolean): void { if (!this.viewModel) { return; } - const items = this.tree.getNode(null).children; - const lastItem = items[items.length - 1]; - if (!lastItem) { + let item; + if (lastFocused) { + item = items[this._mostRecentlyFocusedItemIndex] ?? items[items.length - 1]; + } else { + item = items[items.length - 1]; + } + if (!item) { return; } - this.tree.setFocus([lastItem.element]); + this.tree.setFocus([item.element]); this.tree.domFocus(); } diff --git a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts index 478adb4ffdc..2ce3f017fcf 100644 --- a/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts +++ b/src/vs/workbench/contrib/inlineChat/browser/inlineChatController.ts @@ -1694,6 +1694,6 @@ async function moveToPanelChat(accessor: ServicesAccessor, model: ChatModel | un for (const request of model.getRequests().slice()) { await chatService.adoptRequest(widget.viewModel.model.sessionId, request); } - widget.focusLastMessage(); + widget.focusResponseItem(); } } diff --git a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts index 3c3ac4fb922..af1550e9a6c 100644 --- a/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts +++ b/src/vs/workbench/contrib/inlineChat/test/browser/inlineChatController.test.ts @@ -623,7 +623,7 @@ suite('InlineChatController', function () { override get viewModel() { return { model: targetModel } as any; } - override focusLastMessage() { } + override focusResponseItem() { } }; const r = ctrl.joinCurrentRun(); @@ -671,7 +671,7 @@ suite('InlineChatController', function () { override get viewModel() { return { model: targetModel } as any; } - override focusLastMessage() { } + override focusResponseItem() { } }; const r = ctrl.joinCurrentRun(); diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts index 0b6924cb784..70b2d546ee4 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatController.ts @@ -163,6 +163,6 @@ async function moveToPanelChat(accessor: ServicesAccessor, model: IChatModel | u for (const request of model.getRequests().slice()) { await chatService.adoptRequest(widget.viewModel.model.sessionId, request); } - widget.focusLastMessage(); + widget.focusResponseItem(); } } diff --git a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts index bd9b9f7012b..1e138d397fe 100644 --- a/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts +++ b/src/vs/workbench/contrib/terminalContrib/chat/browser/terminalChatWidget.ts @@ -474,7 +474,7 @@ export class TerminalChatWidget extends Disposable { result: currentRequest.response!.result, followups: currentRequest.response!.followups }); - widget.focusLastMessage(); + widget.focusResponseItem(); this.hide(); } }