Merge pull request #300983 from microsoft/rebornix/hollow-tarantula-image

feat: add image carousel
This commit is contained in:
Peng Lyu
2026-03-11 20:39:36 -07:00
committed by GitHub
13 changed files with 746 additions and 3 deletions

View File

@@ -170,6 +170,10 @@
"name": "vs/workbench/contrib/inlineChat",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/contrib/imageCarousel",
"project": "vscode-workbench"
},
{
"name": "vs/workbench/contrib/chat",
"project": "vscode-workbench"

View File

@@ -75,6 +75,9 @@ export namespace Schemas {
export const vscodeTerminal = 'vscode-terminal';
/** Scheme used for the image carousel editor. */
export const vscodeImageCarousel = 'vscode-image-carousel';
/** Scheme used for code blocks in chat. */
export const vscodeChatCodeBlock = 'vscode-chat-code-block';

View File

@@ -18,6 +18,7 @@ import { IMarkdownString, MarkdownString } from '../../../../../base/common/html
import { Iterable } from '../../../../../base/common/iterator.js';
import { KeyCode } from '../../../../../base/common/keyCodes.js';
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../../../base/common/lifecycle.js';
import { getMediaMime } from '../../../../../base/common/mime.js';
import { Schemas } from '../../../../../base/common/network.js';
import { basename, dirname } from '../../../../../base/common/path.js';
import { ScrollbarVisibility } from '../../../../../base/common/scrollable.js';
@@ -61,6 +62,7 @@ import { toHistoryItemHoverContent } from '../../../scm/browser/scmHistory.js';
import { getHistoryItemEditorTitle } from '../../../scm/browser/util.js';
import { ITerminalService } from '../../../terminal/browser/terminal.js';
import { IChatContentReference } from '../../common/chatService/chatService.js';
import { ChatConfiguration } from '../../common/constants.js';
import { IChatRequestPasteVariableEntry, IChatRequestVariableEntry, IElementVariableEntry, INotebookOutputVariableEntry, IPromptFileVariableEntry, IPromptTextVariableEntry, ISCMHistoryItemVariableEntry, OmittedState, PromptFileVariableKind, ChatRequestToolReferenceEntry, ISCMHistoryItemChangeVariableEntry, ISCMHistoryItemChangeRangeVariableEntry, ITerminalVariableEntry, isStringVariableEntry } from '../../common/attachments/chatVariableEntries.js';
import { ILanguageModelChatMetadataAndIdentifier, ILanguageModelsService } from '../../common/languageModels.js';
import { IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js';
@@ -435,7 +437,10 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
const ref = attachment.references?.[0]?.reference;
resource = ref && URI.isUri(ref) ? ref : undefined;
const clickHandler = async () => {
if (resource) {
if (attachment.value instanceof Uint8Array && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled)) {
const mimeType = getMediaMime(attachment.name) ?? 'image/png';
await commandService.executeCommand('workbench.action.chat.openImageInCarousel', { name: attachment.name, mimeType, data: attachment.value });
} else if (resource) {
await this.openResource(resource, { editorOptions: { preserveFocus: true } }, false, undefined);
}
};
@@ -446,8 +451,16 @@ export class ImageAttachmentWidget extends AbstractChatAttachmentWidget {
this._register(createImageElements(resource, attachment.name, fullName, this.element, attachment.value as Uint8Array, this.hoverService, ariaLabel, currentLanguageModelName, clickHandler, this.currentLanguageModel, attachment.omittedState, this.chatEntitlementService.previewFeaturesDisabled));
this.element.ariaLabel = this.appendDeletionHint(ariaLabel);
// Wire up click + keyboard (Enter/Space) open handlers
const canOpenCarousel = attachment.value instanceof Uint8Array && configurationService.getValue<boolean>(ChatConfiguration.ImageCarouselEnabled);
if (canOpenCarousel || resource) {
this.element.style.cursor = 'pointer';
this._register(registerOpenEditorListeners(this.element, async () => {
await clickHandler();
}));
}
if (resource) {
this.addResourceOpenHandlers(resource, undefined);
instantiationService.invokeFunction(accessor => {
this._register(hookUpResourceAttachmentDragAndContextMenu(accessor, this.element, resource));
});
@@ -475,7 +488,6 @@ function createImageElements(resource: URI | undefined, name: string, fullName:
if (resource) {
element.style.cursor = 'pointer';
disposable.add(dom.addDisposableListener(element, 'click', clickHandler));
}
const supportsVision = modelSupportsVision(currentLanguageModel);
const pillIcon = dom.$('div.chat-attached-context-pill', {}, dom.$((supportsVision && !previewFeaturesDisabled) ? 'span.codicon.codicon-file-media' : 'span.codicon.codicon-warning'));

View File

@@ -479,6 +479,12 @@ configurationRegistry.registerConfiguration({
type: 'boolean',
tags: ['experimental']
},
[ChatConfiguration.ImageCarouselEnabled]: {
default: false,
description: nls.localize('chat.imageCarousel.enabled', "Controls whether clicking an image attachment in chat opens the image carousel viewer."),
type: 'boolean',
tags: ['preview']
},
'chat.undoRequests.restoreInput': {
default: true,
markdownDescription: nls.localize('chat.undoRequests.restoreInput', "Controls whether the input of the chat should be restored when an undo request is made. The input will be filled with the text of the request that was restored."),

View File

@@ -55,6 +55,7 @@ export enum ChatConfiguration {
GrowthNotificationEnabled = 'chat.growthNotification.enabled',
ChatCustomizationMenuEnabled = 'chat.customizationsMenu.enabled',
AutopilotEnabled = 'chat.autopilot.enabled',
ImageCarouselEnabled = 'chat.imageCarousel.enabled',
}
/**

View File

@@ -0,0 +1,36 @@
# Image Carousel
A generic workbench editor for viewing collections of images in a carousel/slideshow UI. Opens as a modal editor pane with navigation arrows, a counter, and a thumbnail strip.
## Architecture
The image carousel is a self-contained workbench contribution that follows the **custom editor** pattern:
- **URI scheme**: `vscode-image-carousel` (registered in `Schemas` in `src/vs/base/common/network.ts`) — used for `EditorInput.resource` identity.
- **Direct editor input**: Callers create `ImageCarouselEditorInput` with a collection and open it directly via `IEditorService.openEditor()`.
- **Image extraction**: `IImageCarouselService.extractImagesFromResponse()` extracts images from chat response tool invocations. The collection ID is derived from the chat response identity (`sessionResource + responseId`).
## How to open the carousel
### From code (generic)
```ts
const collection: IImageCarouselCollection = { id, title, images: [...] };
const input = new ImageCarouselEditorInput(collection, startIndex);
await editorService.openEditor(input, { pinned: true }, MODAL_GROUP);
```
### From chat (via click handler)
Clicking an image attachment pill in chat (when `chat.imageCarousel.enabled` is true) executes the `workbench.action.chat.openImageInCarousel` command, which extracts all images from the chat response and opens them in the carousel. MIME types are resolved via `getMediaMime()` from `src/vs/base/common/mime.ts`.
## Key design decisions
- **Stable DOM skeleton**: Builds DOM once per `setInput()`, updates only changing parts to avoid flash on navigation.
- **Blob URL lifecycle**: Main image URLs tracked in `_imageDisposables` (revoked on nav), thumbnails in `_contentDisposables` (revoked on `clearInput()`).
- **Modal editor**: Opens in `MODAL_GROUP` (-4) as an overlay.
- **Not restorable**: `canSerialize()` returns `false` — image data is in-memory only.
- **Collection ID = chat response identity**: `sessionResource + '_' + responseId` for stable dedup via `EditorInput.matches()`.
- **Preview-gated**: `chat.imageCarousel.enabled` (default `false`, tagged `preview`). When off, clicks fall through to `openResource()`.
- **Exact image matching**: Scans responses in reverse, only opens a collection if the clicked image bytes are found in it (`findIndex` + `VSBuffer.equals`).
- **Keyboard parity**: Uses `registerOpenEditorListeners` (click, double-click, Enter, Space) matching other attachment widgets.

View File

@@ -0,0 +1,114 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import './media/imageCarousel.css';
import { localize, localize2 } from '../../../../nls.js';
import { SyncDescriptor } from '../../../../platform/instantiation/common/descriptors.js';
import { registerSingleton, InstantiationType } from '../../../../platform/instantiation/common/extensions.js';
import { ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js';
import { Registry } from '../../../../platform/registry/common/platform.js';
import { EditorPaneDescriptor, IEditorPaneRegistry } from '../../../browser/editor.js';
import { EditorExtensions, IEditorFactoryRegistry, IEditorSerializer } from '../../../common/editor.js';
import { IEditorService, MODAL_GROUP } from '../../../services/editor/common/editorService.js';
import { VSBuffer } from '../../../../base/common/buffer.js';
import { generateUuid } from '../../../../base/common/uuid.js';
import { IChatWidgetService } from '../../chat/browser/chat.js';
import { IChatResponseViewModel, isResponseVM } from '../../chat/common/model/chatViewModel.js';
import { ImageCarouselEditor } from './imageCarouselEditor.js';
import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';
import { IImageCarouselService, ImageCarouselService } from './imageCarouselService.js';
import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js';
// --- Service Registration ---
registerSingleton(IImageCarouselService, ImageCarouselService, InstantiationType.Delayed);
// --- Editor Pane Registration ---
Registry.as<IEditorPaneRegistry>(EditorExtensions.EditorPane).registerEditorPane(
EditorPaneDescriptor.create(
ImageCarouselEditor,
ImageCarouselEditor.ID,
localize('imageCarouselEditor', "Image Carousel")
),
[
new SyncDescriptor(ImageCarouselEditorInput)
]
);
// --- Serializer ---
class ImageCarouselEditorInputSerializer implements IEditorSerializer {
canSerialize(): boolean {
return false;
}
serialize(): string | undefined {
return undefined;
}
deserialize(): ImageCarouselEditorInput | undefined {
return undefined;
}
}
Registry.as<IEditorFactoryRegistry>(EditorExtensions.EditorFactory)
.registerEditorSerializer(ImageCarouselEditorInput.ID, ImageCarouselEditorInputSerializer);
// --- Actions ---
class OpenImageInCarouselAction extends Action2 {
constructor() {
super({
id: 'workbench.action.chat.openImageInCarousel',
title: localize2('openImageInCarousel', "Open Image in Carousel"),
f1: false
});
}
async run(accessor: ServicesAccessor, args: { name: string; mimeType: string; data: Uint8Array }): Promise<void> {
const editorService = accessor.get(IEditorService);
const chatWidgetService = accessor.get(IChatWidgetService);
const carouselService = accessor.get(IImageCarouselService);
const clickedData = VSBuffer.wrap(args.data);
// Try to find all images from the focused chat widget's responses
const widget = chatWidgetService.lastFocusedWidget;
if (widget?.viewModel) {
const responses = widget.viewModel.getItems().filter((item): item is IChatResponseViewModel => isResponseVM(item));
// Search responses in reverse to find the one containing the clicked image
for (let i = responses.length - 1; i >= 0; i--) {
const collection = await carouselService.extractImagesFromResponse(responses[i]);
if (collection && collection.images.length > 0) {
// Only use this collection if it actually contains the clicked image
const startIndex = collection.images.findIndex(img => img.data.equals(clickedData));
if (startIndex !== -1) {
const input = new ImageCarouselEditorInput(collection, startIndex);
await editorService.openEditor(input, { pinned: true }, MODAL_GROUP);
return;
}
}
}
}
// Fallback: open just the single clicked image
const collection = {
id: generateUuid(),
title: localize('imageCarousel.title', "Image Carousel"),
images: [{
id: generateUuid(),
name: args.name,
mimeType: args.mimeType,
data: VSBuffer.wrap(args.data),
}],
};
const input = new ImageCarouselEditorInput(collection);
await editorService.openEditor(input, { pinned: true }, MODAL_GROUP);
}
}
registerAction2(OpenImageInCarouselAction);

View File

@@ -0,0 +1,244 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { addDisposableListener, clearNode, Dimension, EventType, h } from '../../../../base/browser/dom.js';
import { StandardKeyboardEvent } from '../../../../base/browser/keyboardEvent.js';
import { KeyCode } from '../../../../base/common/keyCodes.js';
import { CancellationToken } from '../../../../base/common/cancellation.js';
import { DisposableStore } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { IEditorOptions } from '../../../../platform/editor/common/editor.js';
import { IThemeService } from '../../../../platform/theme/common/themeService.js';
import { EditorPane } from '../../../browser/parts/editor/editorPane.js';
import { IEditorOpenContext } from '../../../common/editor.js';
import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js';
import { IStorageService } from '../../../../platform/storage/common/storage.js';
import { IEditorGroup } from '../../../services/editor/common/editorGroupsService.js';
import { ImageCarouselEditorInput } from './imageCarouselEditorInput.js';
import { ICarouselImage } from './imageCarouselTypes.js';
export class ImageCarouselEditor extends EditorPane {
static readonly ID = 'workbench.editor.imageCarousel';
private _container: HTMLElement | undefined;
private _currentIndex: number = 0;
private _images: ReadonlyArray<ICarouselImage> = [];
private readonly _contentDisposables = this._register(new DisposableStore());
private readonly _imageDisposables = this._register(new DisposableStore());
private _elements: {
root: HTMLElement;
mainImage: HTMLImageElement;
prevBtn: HTMLButtonElement;
nextBtn: HTMLButtonElement;
counter: HTMLElement;
thumbnails: HTMLElement;
} | undefined;
private _thumbnailElements: HTMLElement[] = [];
constructor(
group: IEditorGroup,
@ITelemetryService telemetryService: ITelemetryService,
@IThemeService themeService: IThemeService,
@IStorageService storageService: IStorageService
) {
super(ImageCarouselEditor.ID, group, telemetryService, themeService, storageService);
}
protected override createEditor(parent: HTMLElement): void {
this._container = h('div.image-carousel-editor').root;
parent.appendChild(this._container);
}
override async setInput(input: ImageCarouselEditorInput, options: IEditorOptions | undefined, context: IEditorOpenContext, token: CancellationToken): Promise<void> {
await super.setInput(input, options, context, token);
this._images = input.collection.images;
this._currentIndex = Math.min(input.startIndex, Math.max(0, input.collection.images.length - 1));
this.buildSlideshow();
}
override clearInput(): void {
this._contentDisposables.clear();
this._imageDisposables.clear();
if (this._container) {
clearNode(this._container);
}
this._elements = undefined;
this._thumbnailElements = [];
super.clearInput();
}
/**
* Build the full DOM skeleton. Called once per setInput.
*/
private buildSlideshow(): void {
if (!this._container) {
return;
}
this._contentDisposables.clear();
this._imageDisposables.clear();
clearNode(this._container);
if (this._images.length === 0) {
const empty = h('div.empty-message');
empty.root.textContent = localize('imageCarousel.noImages', "No images to display");
this._container.appendChild(empty.root);
return;
}
const elements = h('div.slideshow-container', [
h('div.image-area@imageArea', [
h('div.main-image-container', [
h('img.main-image@mainImage'),
]),
h('button.nav-arrow.prev-arrow@prevBtn', { ariaLabel: localize('imageCarousel.previousImage', "Previous image") }, [
h('span.codicon.codicon-chevron-left'),
]),
h('button.nav-arrow.next-arrow@nextBtn', { ariaLabel: localize('imageCarousel.nextImage', "Next image") }, [
h('span.codicon.codicon-chevron-right'),
]),
]),
h('div.image-counter@counter'),
h('div.thumbnails-container@thumbnails'),
]);
this._elements = {
root: elements.root,
mainImage: elements.mainImage as HTMLImageElement,
prevBtn: elements.prevBtn as HTMLButtonElement,
nextBtn: elements.nextBtn as HTMLButtonElement,
counter: elements.counter,
thumbnails: elements.thumbnails,
};
// Navigation listeners
this._contentDisposables.add(addDisposableListener(this._elements.prevBtn, 'click', () => {
if (this._currentIndex > 0) {
this._currentIndex--;
this.updateCurrentImage();
}
}));
this._contentDisposables.add(addDisposableListener(this._elements.nextBtn, 'click', () => {
if (this._currentIndex < this._images.length - 1) {
this._currentIndex++;
this.updateCurrentImage();
}
}));
// Keyboard navigation — handle locally and stop propagation
// so the modal editor's key handler does not block these keys
this._contentDisposables.add(addDisposableListener(elements.root, EventType.KEY_DOWN, e => {
const event = new StandardKeyboardEvent(e);
if (event.keyCode === KeyCode.LeftArrow) {
this.previous();
event.stopPropagation();
event.preventDefault();
} else if (event.keyCode === KeyCode.RightArrow) {
this.next();
event.stopPropagation();
event.preventDefault();
}
}));
elements.root.tabIndex = 0;
// Thumbnails
this._thumbnailElements = [];
for (let i = 0; i < this._images.length; i++) {
const image = this._images[i];
const thumbnail = h('button.thumbnail@root', [
h('img.thumbnail-image@img'),
]);
const btn = thumbnail.root as HTMLButtonElement;
btn.ariaLabel = localize('imageCarousel.thumbnailLabel', "Image {0} of {1}", i + 1, this._images.length);
const img = thumbnail.img as HTMLImageElement;
const blob = new Blob([image.data.buffer.slice(0)], { type: image.mimeType });
const url = URL.createObjectURL(blob);
img.src = url;
img.alt = image.name;
this._contentDisposables.add({ dispose: () => URL.revokeObjectURL(url) });
this._contentDisposables.add(addDisposableListener(btn, 'click', () => {
this._currentIndex = i;
this.updateCurrentImage();
}));
this._elements.thumbnails.appendChild(btn);
this._thumbnailElements.push(btn);
}
this._container.appendChild(elements.root);
// Set initial image
this.updateCurrentImage();
}
/**
* Update only the changing parts: main image src, counter, button states, thumbnail selection.
* No DOM teardown/rebuild — eliminates the blank flash.
*/
private updateCurrentImage(): void {
if (!this._elements) {
return;
}
// Swap main image blob URL
this._imageDisposables.clear();
const currentImage = this._images[this._currentIndex];
const blob = new Blob([currentImage.data.buffer.slice(0)], { type: currentImage.mimeType });
const url = URL.createObjectURL(blob);
this._elements.mainImage.src = url;
this._elements.mainImage.alt = currentImage.name;
this._imageDisposables.add({ dispose: () => URL.revokeObjectURL(url) });
// Update button states
this._elements.prevBtn.disabled = this._currentIndex === 0;
this._elements.nextBtn.disabled = this._currentIndex === this._images.length - 1;
// Update counter
this._elements.counter.textContent = localize('imageCarousel.counter', "{0} / {1}", this._currentIndex + 1, this._images.length);
// Update thumbnail selection
for (let i = 0; i < this._thumbnailElements.length; i++) {
const isActive = i === this._currentIndex;
const thumbnail = this._thumbnailElements[i];
thumbnail.classList.toggle('active', isActive);
if (isActive) {
thumbnail.setAttribute('aria-current', 'page');
} else {
thumbnail.removeAttribute('aria-current');
}
}
}
previous(): void {
if (this._currentIndex > 0) {
this._currentIndex--;
this.updateCurrentImage();
}
}
next(): void {
if (this._currentIndex < this._images.length - 1) {
this._currentIndex++;
this.updateCurrentImage();
}
}
override focus(): void {
super.focus();
this._elements?.root.focus();
}
override layout(dimension: Dimension): void {
if (this._container) {
this._container.style.width = `${dimension.width}px`;
this._container.style.height = `${dimension.height}px`;
}
}
}

View File

@@ -0,0 +1,46 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { EditorInput } from '../../../common/editor/editorInput.js';
import { IUntypedEditorInput } from '../../../common/editor.js';
import { URI } from '../../../../base/common/uri.js';
import { Schemas } from '../../../../base/common/network.js';
import { IImageCarouselCollection } from './imageCarouselTypes.js';
export class ImageCarouselEditorInput extends EditorInput {
static readonly ID = 'workbench.input.imageCarousel';
private _resource: URI;
constructor(
public readonly collection: IImageCarouselCollection,
public readonly startIndex: number = 0
) {
super();
this._resource = URI.from({
scheme: Schemas.vscodeImageCarousel,
path: `/${encodeURIComponent(collection.id)}`,
});
}
get typeId(): string {
return ImageCarouselEditorInput.ID;
}
get resource(): URI {
return this._resource;
}
override getName(): string {
return this.collection.title;
}
override matches(other: EditorInput | IUntypedEditorInput): boolean {
if (other instanceof ImageCarouselEditorInput) {
return other.collection.id === this.collection.id;
}
return false;
}
}

View File

@@ -0,0 +1,96 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { decodeBase64, VSBuffer } from '../../../../base/common/buffer.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
import { localize } from '../../../../nls.js';
import { createDecorator } from '../../../../platform/instantiation/common/instantiation.js';
import { ICarouselImage, IImageCarouselCollection } from './imageCarouselTypes.js';
import { IChatResponseViewModel } from '../../chat/common/model/chatViewModel.js';
import { IChatToolInvocation, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from '../../chat/common/chatService/chatService.js';
import { isToolResultInputOutputDetails, isToolResultOutputDetails, IToolResultOutputDetails } from '../../chat/common/tools/languageModelToolsService.js';
export const IImageCarouselService = createDecorator<IImageCarouselService>('imageCarouselService');
export interface IImageCarouselService {
readonly _serviceBrand: undefined;
/**
* Extract images from a chat response's tool invocations.
*/
extractImagesFromResponse(response: IChatResponseViewModel): Promise<IImageCarouselCollection | undefined>;
}
export class ImageCarouselService extends Disposable implements IImageCarouselService {
readonly _serviceBrand: undefined;
async extractImagesFromResponse(response: IChatResponseViewModel): Promise<IImageCarouselCollection | undefined> {
const images: ICarouselImage[] = [];
for (const item of response.response.value) {
if (item.kind === 'toolInvocation' || item.kind === 'toolInvocationSerialized') {
const toolImages = this.extractImagesFromToolInvocation(item);
images.push(...toolImages);
}
}
if (images.length === 0) {
return undefined;
}
return {
id: response.sessionResource.toString() + '_' + response.id,
title: localize('imageCarousel.title', "Image Carousel"),
images
};
}
private extractImagesFromToolInvocation(toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): ICarouselImage[] {
const images: ICarouselImage[] = [];
const resultDetails = IChatToolInvocation.resultDetails(toolInvocation);
const pushImage = (mimeType: string, data: VSBuffer) => {
images.push({
id: `${toolInvocation.toolCallId}_${images.length}`,
name: localize('imageCarousel.imageName', "Image {0}", images.length + 1),
mimeType,
data,
source: localize('imageCarousel.toolSource', "Tool: {0}", toolInvocation.toolId)
});
};
if (isToolResultInputOutputDetails(resultDetails)) {
for (const outputItem of resultDetails.output) {
if (outputItem.type === 'embed' && outputItem.mimeType?.startsWith('image/') && !outputItem.isText) {
pushImage(outputItem.mimeType, decodeBase64(outputItem.value));
}
}
}
else if (isToolResultOutputDetails(resultDetails)) {
const output = resultDetails.output;
if (output.mimeType?.startsWith('image/')) {
const data = this.getImageDataFromOutputDetails(resultDetails, toolInvocation);
if (data) {
pushImage(output.mimeType, data);
}
}
}
return images;
}
private getImageDataFromOutputDetails(resultDetails: IToolResultOutputDetails, toolInvocation: IChatToolInvocation | IChatToolInvocationSerialized): VSBuffer | undefined {
if (toolInvocation.kind === 'toolInvocationSerialized') {
const serializedDetails = resultDetails as unknown as IToolResultOutputDetailsSerialized;
if (serializedDetails.output.base64Data) {
return decodeBase64(serializedDetails.output.base64Data);
}
return undefined;
} else {
return resultDetails.output.value;
}
}
}

View File

@@ -0,0 +1,22 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { URI } from '../../../../base/common/uri.js';
import { VSBuffer } from '../../../../base/common/buffer.js';
export interface ICarouselImage {
readonly id: string;
readonly name: string;
readonly mimeType: string;
readonly data: VSBuffer;
readonly uri?: URI;
readonly source?: string;
}
export interface IImageCarouselCollection {
readonly id: string;
readonly title: string;
readonly images: ReadonlyArray<ICarouselImage>;
}

View File

@@ -0,0 +1,158 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
.image-carousel-editor {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
}
.image-carousel-editor .empty-message {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--vscode-descriptionForeground);
font-size: 14px;
}
.image-carousel-editor .slideshow-container {
display: flex;
flex-direction: column;
height: 100%;
padding: 12px;
gap: 8px;
}
.image-carousel-editor .slideshow-container:focus,
.image-carousel-editor .slideshow-container:focus-visible {
outline: none !important;
}
/* Image viewing area */
.image-carousel-editor .image-area {
flex: 1;
min-height: 0;
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--vscode-editorWidget-background);
border-radius: 6px;
}
.image-carousel-editor .main-image-container {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: 24px 48px;
}
.image-carousel-editor .main-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
border-radius: 4px;
}
/* Overlay navigation arrows */
.image-carousel-editor .nav-arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
border-radius: 50%;
cursor: pointer;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1;
}
.monaco-workbench.monaco-reduce-motion .image-carousel-editor .nav-arrow {
transition: none;
}
.image-carousel-editor .image-area:hover .nav-arrow:not(:disabled) {
opacity: 0.8;
}
.image-carousel-editor .nav-arrow:hover:not(:disabled) {
opacity: 1 !important;
background: var(--vscode-button-background);
color: var(--vscode-button-foreground);
}
.image-carousel-editor .nav-arrow:disabled {
opacity: 0 !important;
cursor: default;
pointer-events: none;
}
.image-carousel-editor .nav-arrow.prev-arrow {
left: 8px;
}
.image-carousel-editor .nav-arrow.next-arrow {
right: 8px;
}
/* Counter */
.image-carousel-editor .image-counter {
text-align: center;
color: var(--vscode-descriptionForeground);
font-size: 12px;
padding: 2px 0;
}
/* Thumbnail strip */
.image-carousel-editor .thumbnails-container {
display: flex;
gap: 6px;
overflow-x: auto;
padding: 4px 0;
justify-content: center;
flex-shrink: 0;
}
.image-carousel-editor .thumbnail {
width: 48px;
height: 48px;
border: 2px solid transparent;
border-radius: 4px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s ease, opacity 0.15s ease;
flex-shrink: 0;
opacity: 0.6;
padding: 0;
background: none;
}
.image-carousel-editor .thumbnail:hover {
opacity: 1;
border-color: var(--vscode-focusBorder);
}
.image-carousel-editor .thumbnail.active {
opacity: 1;
border-color: var(--vscode-focusBorder);
}
.image-carousel-editor .thumbnail-image {
width: 100%;
height: 100%;
object-fit: cover;
}

View File

@@ -212,6 +212,7 @@ import './contrib/inlineChat/browser/inlineChat.contribution.js';
import './contrib/mcp/browser/mcp.contribution.js';
import './contrib/chat/browser/chatSessions/chatSessions.contribution.js';
import './contrib/chat/browser/contextContrib/chatContext.contribution.js';
import './contrib/imageCarousel/browser/imageCarousel.contribution.js';
// Interactive
import './contrib/interactive/browser/interactive.contribution.js';