Path: blob/main/src/vs/workbench/contrib/chat/browser/chatDebug/chatCustomizationDiscoveryRenderer.ts
13406 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 '../widget/chatContentParts/media/chatInlineAnchorWidget.css';6import * as DOM from '../../../../../base/browser/dom.js';7import { Button } from '../../../../../base/browser/ui/button/button.js';8import { getDefaultHoverDelegate } from '../../../../../base/browser/ui/hover/hoverDelegateFactory.js';9import { Codicon } from '../../../../../base/common/codicons.js';10import { DisposableStore } from '../../../../../base/common/lifecycle.js';11import { ThemeIcon } from '../../../../../base/common/themables.js';12import { URI } from '../../../../../base/common/uri.js';13import { dirname } from '../../../../../base/common/resources.js';14import { ILanguageService } from '../../../../../editor/common/languages/language.js';15import { getIconClasses } from '../../../../../editor/common/services/getIconClasses.js';16import { IModelService } from '../../../../../editor/common/services/model.js';17import { localize } from '../../../../../nls.js';18import { FileKind } from '../../../../../platform/files/common/files.js';19import { IHoverService } from '../../../../../platform/hover/browser/hover.js';20import { ILabelService } from '../../../../../platform/label/common/label.js';21import { IOpenerService } from '../../../../../platform/opener/common/opener.js';22import { IChatDebugCustomizationLogEntry, IChatDebugEventCustomizationSummaryContent, IChatDebugEventFileListContent } from '../../common/chatDebugService.js';23import { InlineAnchorWidget } from '../widget/chatContentParts/chatInlineAnchorWidget.js';24import { setupCollapsibleToggle } from './chatDebugCollapsible.js';2526const $ = DOM.$;2728/**29* Map a discovery type string to its corresponding settings key.30*/31function getSettingsKeyForDiscoveryType(discoveryType: string): string | undefined {32switch (discoveryType) {33case 'prompt': return 'chat.promptFilesLocations';34case 'instructions': return 'chat.instructionsFilesLocations';35case 'agent': return 'chat.agentFilesLocations';36case 'skill': return 'chat.agentSkillsLocations';37case 'hook': return 'chat.hookFilesLocations';38default: return undefined;39}40}4142/**43* Get a display label for a file's location.44* Extension files show the extension ID,45* all other files show the relative (or tildified) parent folder path.46*/47function getFileLocationLabel(file: { uri: URI; storage?: string; extensionId?: string }, labelService: ILabelService, discoveryType?: string): string {48if (file.extensionId) {49return file.extensionId;50}51// Skills live inside individual skill folders (e.g. .github/skills/foo/SKILL.md),52// so group by the parent of the skill folder for a more useful label.53const parentDir = discoveryType === 'skill' ? dirname(dirname(file.uri)) : dirname(file.uri);54return labelService.getUriLabel(parentDir, { relative: true });55}5657/**58* Create a file link element styled like the chat panel's InlineAnchorWidget.59*/60function createInlineFileLink(uri: URI, displayText: string, fileKind: FileKind, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService, disposables: DisposableStore, hoverSuffix?: string): HTMLElement {61const link = $(`a.${InlineAnchorWidget.className}.show-file-icons`);62link.tabIndex = -1;6364const iconEl = DOM.append(link, $('span.icon'));65const iconClasses = getIconClasses(modelService, languageService, uri, fileKind);66iconEl.classList.add(...iconClasses);6768DOM.append(link, $('span.icon-label', undefined, displayText));6970const relativeLabel = labelService.getUriLabel(uri, { relative: true });71const hoverText = hoverSuffix ? `${relativeLabel} ${hoverSuffix}` : relativeLabel;72disposables.add(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, hoverText));73disposables.add(DOM.addDisposableListener(link, DOM.EventType.CLICK, (e) => {74e.preventDefault();75e.stopPropagation();76openerService.open(uri, { editorOptions: { preserveFocus: true } });77}));7879return link;80}8182/**83* Set up roving tabindex with arrow-key navigation on a list of rows.84* The first row starts with tabIndex 0; the rest get -1.85* Up/Down arrow keys move focus, Home/End jump to first/last.86* Enter on a focused row activates the associated action.87*/88function setupFileListNavigation(listEl: HTMLElement, rows: { element: HTMLElement; activate: () => void }[], disposables: DisposableStore): void {89if (rows.length === 0) {90return;91}9293for (let i = 0; i < rows.length; i++) {94rows[i].element.tabIndex = i === 0 ? 0 : -1;95rows[i].element.setAttribute('role', 'listitem');96}9798disposables.add(DOM.addDisposableListener(listEl, DOM.EventType.KEY_DOWN, (e: KeyboardEvent) => {99const target = e.target as HTMLElement;100const index = rows.findIndex(r => r.element === target);101if (index === -1) {102return;103}104105let nextIndex: number | undefined;106switch (e.key) {107case 'ArrowDown':108nextIndex = Math.min(index + 1, rows.length - 1);109break;110case 'ArrowUp':111nextIndex = Math.max(index - 1, 0);112break;113case 'Home':114nextIndex = 0;115break;116case 'End':117nextIndex = rows.length - 1;118break;119case 'Enter': {120rows[index].activate();121e.preventDefault();122return;123}124}125126if (nextIndex !== undefined && nextIndex !== index) {127e.preventDefault();128rows[index].element.tabIndex = -1;129rows[nextIndex].element.tabIndex = 0;130rows[nextIndex].element.focus();131}132}));133}134135/**136* Render a file list resolved content as a rich HTML element.137*/138export function renderCustomizationDiscoveryContent(content: IChatDebugEventFileListContent, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService, scrollable?: { scanDomNode(): void }): { element: HTMLElement; disposables: DisposableStore } {139const disposables = new DisposableStore();140const container = $('div.chat-debug-file-list');141container.tabIndex = 0;142143const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1);144DOM.append(container, $('div.chat-debug-file-list-title', undefined, localize('chatDebug.discoveryResults', "{0} Discovery Results", capitalizedType)));145DOM.append(container, $('div.chat-debug-file-list-summary', undefined, localize('chatDebug.totalFiles', "Total files: {0}", content.files.length)));146147// Loaded files - grouped by source location148const loaded = content.files.filter(f => f.status === 'loaded');149if (loaded.length > 0) {150const section = DOM.append(container, $('div.chat-debug-file-list-section'));151DOM.append(section, $('div.chat-debug-file-list-section-title', undefined,152localize('chatDebug.loadedFiles', "Loaded ({0})", loaded.length)));153154// Group files by location label (extension ID or folder path)155const groups = new Map<string, typeof loaded>();156for (const file of loaded) {157const key = getFileLocationLabel(file, labelService, content.discoveryType);158let group = groups.get(key);159if (!group) {160group = [];161groups.set(key, group);162}163group.push(file);164}165166const listEl = DOM.append(section, $('div.chat-debug-file-list-rows'));167listEl.setAttribute('role', 'list');168listEl.setAttribute('aria-label', localize('chatDebug.loadedFilesList', "Loaded files"));169170const rows: { element: HTMLElement; activate: () => void }[] = [];171for (const [locationLabel, files] of groups) {172// Group header - show the source location173const groupHeader = DOM.append(listEl, $('div.chat-debug-file-list-group-header'));174const firstFile = files[0];175if (firstFile.extensionId) {176const link = DOM.append(groupHeader, $('a.chat-debug-file-list-group-label.chat-debug-file-list-badge-link'));177link.textContent = locationLabel;178link.tabIndex = -1;179disposables.add(hoverService.setupManagedHover(getDefaultHoverDelegate('element'), link, localize('chatDebug.openExtension', "Open {0} in Extensions", firstFile.extensionId)));180disposables.add(DOM.addDisposableListener(link, DOM.EventType.CLICK, (e) => {181e.preventDefault();182e.stopPropagation();183openerService.open(URI.parse(`command:extension.open?${encodeURIComponent(JSON.stringify([firstFile.extensionId]))}`), { allowCommands: true });184}));185} else {186DOM.append(groupHeader, $('span.chat-debug-file-list-group-label', undefined, locationLabel));187}188189for (const file of files) {190const row = DOM.append(listEl, $('div.chat-debug-file-list-row'));191DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(Codicon.check)}`));192row.appendChild(createInlineFileLink(file.uri, file.name ?? file.uri.path, FileKind.FILE, openerService, modelService, languageService, hoverService, labelService, disposables));193const relativeLabel = labelService.getUriLabel(file.uri, { relative: true });194row.setAttribute('aria-label', relativeLabel);195const uri = file.uri;196rows.push({ element: row, activate: () => openerService.open(uri, { editorOptions: { preserveFocus: true } }) });197}198}199setupFileListNavigation(listEl, rows, disposables);200}201202// Skipped files - grouped by skip reason203const skipped = content.files.filter(f => f.status === 'skipped');204if (skipped.length > 0) {205const section = DOM.append(container, $('div.chat-debug-file-list-section'));206DOM.append(section, $('div.chat-debug-file-list-section-title', undefined,207localize('chatDebug.skippedFiles', "Skipped ({0})", skipped.length)));208209// Group files by skip reason210const groups = new Map<string, typeof skipped>();211for (const file of skipped) {212const key = file.skipReason ?? localize('chatDebug.unknown', "unknown");213let group = groups.get(key);214if (!group) {215group = [];216groups.set(key, group);217}218group.push(file);219}220221const listEl = DOM.append(section, $('div.chat-debug-file-list-rows'));222listEl.setAttribute('role', 'list');223listEl.setAttribute('aria-label', localize('chatDebug.skippedFilesList', "Skipped files"));224225const rows: { element: HTMLElement; activate: () => void }[] = [];226for (const [reasonLabel, files] of groups) {227// Group header - show the skip reason228const groupHeader = DOM.append(listEl, $('div.chat-debug-file-list-group-header'));229DOM.append(groupHeader, $('span.chat-debug-file-list-group-label', undefined, reasonLabel));230231for (const file of files) {232const row = DOM.append(listEl, $('div.chat-debug-file-list-row'));233DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(Codicon.close)}`));234235// Build per-file detail (error message / duplicate info)236let detail = '';237if (file.errorMessage) {238detail += file.errorMessage;239}240if (file.duplicateOf) {241if (detail) {242detail += ', ';243}244detail += localize('chatDebug.duplicateOf', "duplicate of {0}", file.duplicateOf.path);245}246247row.appendChild(createInlineFileLink(file.uri, file.name ?? file.uri.path, FileKind.FILE, openerService, modelService, languageService, hoverService, labelService, disposables));248if (detail) {249DOM.append(row, $('span.chat-debug-file-list-detail', undefined, ` (${detail})`));250}251const relativeLabel = labelService.getUriLabel(file.uri, { relative: true });252row.setAttribute('aria-label', relativeLabel);253const uri = file.uri;254rows.push({ element: row, activate: () => openerService.open(uri, { editorOptions: { preserveFocus: true } }) });255}256}257setupFileListNavigation(listEl, rows, disposables);258}259260// Source folders (paths attempted) - collapsible, initially collapsed261if (content.sourceFolders && content.sourceFolders.length > 0) {262const sectionEl = DOM.append(container, $('div.chat-debug-message-section'));263264const header = DOM.append(sectionEl, $('div.chat-debug-message-section-header'));265266const chevron = DOM.append(header, $('span.chat-debug-message-section-chevron'));267DOM.append(header, $('span.chat-debug-message-section-title', undefined,268localize('chatDebug.sourceFolders', "Sources ({0})", content.sourceFolders.length)));269270// Settings gear button on the right side of the header271const settingsKey = getSettingsKeyForDiscoveryType(content.discoveryType);272if (settingsKey) {273const gearBtn = disposables.add(new Button(header, {274title: localize('chatDebug.openSettingsTooltip', "Configure locations"),275ariaLabel: localize('chatDebug.configureLocations', "Configure locations"),276hoverDelegate: getDefaultHoverDelegate('mouse'),277}));278gearBtn.icon = Codicon.settingsGear;279gearBtn.element.classList.add('chat-debug-settings-gear');280disposables.add(DOM.addDisposableListener(gearBtn.element, DOM.EventType.MOUSE_ENTER, () => {281header.classList.add('chat-debug-settings-gear-header-passthrough');282}));283disposables.add(DOM.addDisposableListener(gearBtn.element, DOM.EventType.MOUSE_LEAVE, () => {284header.classList.remove('chat-debug-settings-gear-header-passthrough');285}));286disposables.add(gearBtn.onDidClick((e) => {287if (e) {288DOM.EventHelper.stop(e, true);289}290openerService.open(URI.parse(`command:workbench.action.openSettings?${encodeURIComponent(JSON.stringify([`@id:${settingsKey}`]))}`), { allowCommands: true });291}));292}293294const contentEl = DOM.append(sectionEl, $('div.chat-debug-source-folder-content'));295contentEl.tabIndex = 0;296contentEl.setAttribute('role', 'region');297contentEl.setAttribute('aria-label', localize('chatDebug.sourceFoldersContent', "Source folders"));298299const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1);300const sourcesCaption = capitalizedType.endsWith('s') ? capitalizedType : capitalizedType + 's';301DOM.append(contentEl, $('div.chat-debug-source-folder-note', undefined,302localize('chatDebug.sourcesNote', "{0} were discovered by checking the following sources in order:", sourcesCaption)));303for (let i = 0; i < content.sourceFolders.length; i++) {304const folder = content.sourceFolders[i];305const row = DOM.append(contentEl, $('div.chat-debug-source-folder-row'));306DOM.append(row, $('span.chat-debug-source-folder-index', undefined, `${i + 1}.`));307DOM.append(row, $('span.chat-debug-source-folder-label', undefined, folder.uri.path));308}309310setupCollapsibleToggle(chevron, header, contentEl, disposables, /* initiallyCollapsed */ true, scrollable);311}312313return { element: container, disposables };314}315316/**317* Convert a file list content to plain text for clipboard / editor output.318*/319export function fileListToPlainText(content: IChatDebugEventFileListContent): string {320const lines: string[] = [];321const capitalizedType = content.discoveryType.charAt(0).toUpperCase() + content.discoveryType.slice(1);322lines.push(localize('chatDebug.plainText.discoveryResults', "{0} Discovery Results", capitalizedType));323lines.push(localize('chatDebug.plainText.totalFiles', "Total files: {0}", content.files.length));324lines.push('');325326const loaded = content.files.filter(f => f.status === 'loaded');327const skipped = content.files.filter(f => f.status === 'skipped');328329if (loaded.length > 0) {330lines.push(localize('chatDebug.plainText.loaded', "Loaded ({0})", loaded.length));331// Group by location332const groups = new Map<string, typeof loaded>();333for (const f of loaded) {334const parentDir = content.discoveryType === 'skill' ? dirname(dirname(f.uri)) : dirname(f.uri);335const key = f.extensionId ?? parentDir.path;336let group = groups.get(key);337if (!group) {338group = [];339groups.set(key, group);340}341group.push(f);342}343for (const [locationLabel, files] of groups) {344lines.push(` ${locationLabel}`);345for (const f of files) {346const label = f.name ?? f.uri.path;347lines.push(` \u2713 ${label}`);348}349}350lines.push('');351}352353if (skipped.length > 0) {354lines.push(localize('chatDebug.plainText.skipped', "Skipped ({0})", skipped.length));355// Group by skip reason356const skippedGroups = new Map<string, typeof skipped>();357for (const f of skipped) {358const key = f.skipReason ?? localize('chatDebug.plainText.unknown', "unknown");359let group = skippedGroups.get(key);360if (!group) {361group = [];362skippedGroups.set(key, group);363}364group.push(f);365}366for (const [reasonLabel, files] of skippedGroups) {367lines.push(` ${reasonLabel}`);368for (const f of files) {369const label = f.name ?? f.uri.path;370let detail = ` \u2717 ${label}`;371if (f.errorMessage || f.duplicateOf) {372const parts: string[] = [];373if (f.errorMessage) {374parts.push(f.errorMessage);375}376if (f.duplicateOf) {377parts.push(localize('chatDebug.plainText.duplicateOf', "duplicate of {0}", f.duplicateOf.path));378}379detail += ` (${parts.join(', ')})`;380}381lines.push(detail);382}383}384}385386if (content.sourceFolders && content.sourceFolders.length > 0) {387lines.push('');388lines.push(localize('chatDebug.plainText.sourceFolders', "Sources ({0})", content.sourceFolders.length));389for (const folder of content.sourceFolders) {390lines.push(` ${folder.uri.path}`);391}392}393394return lines.join('\n');395}396397/**398* Get a human-readable section title for a resolution log category.399*/400function getCategorySectionTitle(category: IChatDebugCustomizationLogEntry['category'], count: number): string {401switch (category) {402case 'applying': return localize('chatDebug.customization.instructions', "Instructions ({0})", count);403case 'referenced': return localize('chatDebug.customization.referenced', "Referenced ({0})", count);404case 'skill': return localize('chatDebug.customization.skill', "Skills ({0})", count);405case 'custom-agent': return localize('chatDebug.customization.customAgent', "Agents ({0})", count);406case 'hook': return localize('chatDebug.customization.hook', "Hooks ({0})", count);407case 'skipped': return localize('chatDebug.customization.skipped', "Skipped ({0})", count);408}409}410411/**412* Render a customization summary showing per-file resolution logs413* from the instructions context computer.414*/415export function renderCustomizationSummaryContent(content: IChatDebugEventCustomizationSummaryContent, openerService: IOpenerService, modelService: IModelService, languageService: ILanguageService, hoverService: IHoverService, labelService: ILabelService, scrollable?: { scanDomNode(): void }): { element: HTMLElement; disposables: DisposableStore } {416const disposables = new DisposableStore();417const container = $('div.chat-debug-customization-summary');418container.tabIndex = 0;419420// Title with counts and duration421const mainSection = DOM.append(container, $('div.chat-debug-file-list'));422DOM.append(mainSection, $('div.chat-debug-file-list-title', undefined,423localize('chatDebug.customizationTitle', "Customization Resolution Results")));424DOM.append(mainSection, $('div.chat-debug-file-list-summary', undefined,425localize('chatDebug.customizationSummary', "{0} instructions, {1} skills, {2} agents, {3} hooks, {4} skipped in {5}ms",426content.counts.instructions, content.counts.skills, content.counts.agents, content.counts.hooks, content.counts.skipped, content.durationInMillis.toFixed(1))));427428// Group entries by display section: instructions (applying+referenced), skills, agents, skipped429// Instructions section merges applying + referenced430const instructionEntries = content.resolutionLogs.filter(e => e.category === 'applying' || e.category === 'referenced');431const skillEntries = content.resolutionLogs.filter(e => e.category === 'skill');432const agentEntries = content.resolutionLogs.filter(e => e.category === 'custom-agent');433const hookEntries = content.resolutionLogs.filter(e => e.category === 'hook');434const skippedEntries = content.resolutionLogs.filter(e => e.category === 'skipped');435436const sections: { title: string; icon: ThemeIcon; entries: readonly IChatDebugCustomizationLogEntry[] }[] = [437{ title: getCategorySectionTitle('applying', instructionEntries.length), icon: Codicon.book, entries: instructionEntries },438{ title: getCategorySectionTitle('skill', skillEntries.length), icon: Codicon.lightbulb, entries: skillEntries },439{ title: getCategorySectionTitle('custom-agent', agentEntries.length), icon: Codicon.agent, entries: agentEntries },440{ title: getCategorySectionTitle('hook', hookEntries.length), icon: Codicon.zap, entries: hookEntries },441{ title: getCategorySectionTitle('skipped', skippedEntries.length), icon: Codicon.close, entries: skippedEntries },442];443444for (const { title, icon, entries } of sections) {445if (entries.length === 0) {446continue;447}448449const section = DOM.append(mainSection, $('div.chat-debug-file-list-section'));450DOM.append(section, $('div.chat-debug-file-list-section-title', undefined, title));451452const listEl = DOM.append(section, $('div.chat-debug-file-list-rows'));453listEl.setAttribute('role', 'list');454listEl.setAttribute('aria-label', title);455456const rows: { element: HTMLElement; activate: () => void }[] = [];457458// For hooks, group entries by lifecycle event (stored in reason).459const isHookSection = entries.length > 0 && entries[0].category === 'hook';460if (isHookSection) {461// Collect entries by hook type, preserving insertion order.462const groupedByType = new Map<string, IChatDebugCustomizationLogEntry[]>();463for (const entry of entries) {464const hookType = entry.reason ?? '';465let group = groupedByType.get(hookType);466if (!group) {467group = [];468groupedByType.set(hookType, group);469}470group.push(entry);471}472473for (const [hookType, groupEntries] of groupedByType) {474if (hookType) {475DOM.append(listEl, $('div.chat-debug-file-list-group-header', undefined, hookType));476}477for (const entry of groupEntries) {478const row = DOM.append(listEl, $('div.chat-debug-file-list-row'));479DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(icon)}`));480481if (entry.uri) {482row.appendChild(createInlineFileLink(483entry.uri, entry.name, FileKind.FILE,484openerService, modelService, languageService, hoverService, labelService, disposables,485));486const uri = entry.uri;487rows.push({ element: row, activate: () => openerService.open(uri, { editorOptions: { preserveFocus: true } }) });488} else {489DOM.append(row, $('span', undefined, entry.name));490}491row.setAttribute('aria-label', entry.reason ? `${entry.name} — ${entry.reason}` : entry.name);492}493}494} else {495for (const entry of entries) {496const row = DOM.append(listEl, $('div.chat-debug-file-list-row'));497DOM.append(row, $(`span.chat-debug-file-list-icon${ThemeIcon.asCSSSelector(icon)}`));498499// Hide the reason for skills (e.g. "local") and custom-agents — it's noise in the UI.500const showReason = entry.category !== 'skill' && entry.category !== 'custom-agent';501502if (entry.uri) {503row.appendChild(createInlineFileLink(504entry.uri, entry.name, FileKind.FILE,505openerService, modelService, languageService, hoverService, labelService, disposables,506showReason ? entry.reason : undefined507));508const uri = entry.uri;509rows.push({ element: row, activate: () => openerService.open(uri, { editorOptions: { preserveFocus: true } }) });510} else {511DOM.append(row, $('span', undefined, entry.name));512}513514if (showReason && entry.reason) {515DOM.append(row, $('span.chat-debug-file-list-detail', undefined, ` — ${entry.reason}`));516}517row.setAttribute('aria-label', entry.reason ? `${entry.name} — ${entry.reason}` : entry.name);518}519}520setupFileListNavigation(listEl, rows, disposables);521}522523if (content.resolutionLogs.length === 0) {524DOM.append(mainSection, $('div.chat-debug-file-list-summary', undefined,525localize('chatDebug.noResolutionLogs', "No resolution logs")));526}527528return { element: container, disposables };529}530531/**532* Serialize a customization summary to plain text for clipboard / full-screen.533*/534export function customizationSummaryToPlainText(content: IChatDebugEventCustomizationSummaryContent): string {535const lines: string[] = [];536537lines.push(localize('chatDebug.plainText.customizationTitle', "Customization Resolution Results"));538lines.push(localize('chatDebug.plainText.customizationSummary', "{0} instructions, {1} skills, {2} agents, {3} hooks, {4} skipped in {5}ms",539content.counts.instructions, content.counts.skills, content.counts.agents, content.counts.hooks, content.counts.skipped, content.durationInMillis.toFixed(1)));540lines.push('');541for (const entry of content.resolutionLogs) {542const detail = entry.reason ? `${entry.name} — ${entry.reason}` : entry.name;543lines.push(` [${entry.category}] ${detail}`);544}545546return lines.join('\n');547}548549550