[CmdPal] JS/TS Extensions Phase 7: protocol additions + sample/doc fixes

Adds details-size support and prefix-select handling, documents the deferred lifecycle and drag-drop gaps, fixes the image sample, and finalizes the npm author publisher fallback (dropping the inert cmdpal.capabilities).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d243b1e9-40fb-4aed-aa60-beb5e80f7d91
This commit is contained in:
Michael Jolley
2026-07-29 12:22:34 -05:00
committed by Michael Jolley
parent 39a52af9dc
commit b547ad0f24
14 changed files with 239 additions and 133 deletions

View File

@@ -254,9 +254,45 @@ internal static class JSModelMapper
Body = GetString(detailsProp, "body") ?? string.Empty,
HeroImage = GetIcon(detailsProp, "heroImage"),
Metadata = ParseMetadata(detailsProp, connection),
Size = ParseContentSize(detailsProp),
};
}
/// <summary>
/// Reads the optional details "size" field. Accepts either the string names
/// (small, medium, large) or the numeric <see cref="ContentSize"/> value the
/// host uses (0, 1, 2). Defaults to <see cref="ContentSize.Small"/>.
/// </summary>
internal static ContentSize ParseContentSize(JsonElement parent)
{
if (!TryGetProperty(parent, "size", out var sizeProp))
{
return ContentSize.Small;
}
if (sizeProp.ValueKind == JsonValueKind.Number && sizeProp.TryGetInt32(out var numeric))
{
return numeric switch
{
(int)ContentSize.Medium => ContentSize.Medium,
(int)ContentSize.Large => ContentSize.Large,
_ => ContentSize.Small,
};
}
if (sizeProp.ValueKind == JsonValueKind.String)
{
return sizeProp.GetString()?.ToLowerInvariant() switch
{
"medium" => ContentSize.Medium,
"large" => ContentSize.Large,
_ => ContentSize.Small,
};
}
return ContentSize.Small;
}
internal static IContextItem[] ParseContextItems(JsonElement parent, string name, JsonRpcConnection connection)
{
if (!TryGetProperty(parent, name, out var arrayProp) || arrayProp.ValueKind != JsonValueKind.Array)

View File

@@ -223,6 +223,105 @@ public class JSAdapterProxyTests
Assert.AreEqual("Item B", items[2].Title);
}
[TestMethod]
public void ListPage_MapsDetailsSizeFromStringAndNumber()
{
using var fake = new JSFakeExtension();
fake.OnResult("provider/getCommand", """{ "id": "list1", "pageType": "listPage", "name": "My List" }""");
var itemsJson =
"""
{
"items": [
{ "title": "Large by name", "details": { "title": "A", "size": "large" } },
{ "title": "Medium by number", "details": { "title": "B", "size": 1 } },
{ "title": "Default size", "details": { "title": "C" } }
]
}
""";
fake.OnResult("listPage/getItems", itemsJson);
var provider = CreateProvider(fake);
var page = (IListPage)provider.GetCommand("list1")!;
var items = page.GetItems();
Assert.AreEqual((int)ContentSize.Large, GetDetailsSize(items[0].Details));
Assert.AreEqual((int)ContentSize.Medium, GetDetailsSize(items[1].Details));
Assert.AreEqual((int)ContentSize.Small, GetDetailsSize(items[2].Details));
}
private static int GetDetailsSize(IDetails? details)
{
Assert.IsNotNull(details);
var provider = details as IExtendedAttributesProvider;
Assert.IsNotNull(provider, "Details should expose extended attributes for its size.");
var properties = provider!.GetProperties();
Assert.IsNotNull(properties);
Assert.IsTrue(properties!.TryGetValue("Size", out var size));
return (int)size!;
}
[TestMethod]
public void ListPage_DetailsCommandInvokeSendsCommandInvokeWithId()
{
using var fake = new JSFakeExtension();
fake.OnResult("provider/getCommand", """{ "id": "list1", "pageType": "listPage", "name": "My List" }""");
var itemsJson =
"""
{
"items": [
{
"title": "Item with detail buttons",
"details": {
"title": "Detail",
"metadata": [
{
"key": "actions",
"data": {
"type": "commands",
"commands": [
{ "id": "details-cmd-1", "name": "Do It" }
]
}
}
]
}
}
]
}
""";
fake.OnResult("listPage/getItems", itemsJson);
string? invokedCommandId = null;
fake.OnRequest("command/invoke", element =>
{
invokedCommandId = element.GetProperty("commandId").GetString();
return new JsonObject { ["Kind"] = 4 };
});
var provider = CreateProvider(fake);
var page = (IListPage)provider.GetCommand("list1")!;
var items = page.GetItems();
var details = items[0].Details;
Assert.IsNotNull(details, "The list item should carry details.");
var commandsElement = Array.Find(
details!.Metadata,
e => e.Data is IDetailsCommands);
Assert.IsNotNull(commandsElement, "The details metadata should include a commands element.");
var detailsCommands = (IDetailsCommands)commandsElement!.Data!;
Assert.AreEqual(1, detailsCommands.Commands.Length);
var invokable = (IInvokableCommand)detailsCommands.Commands[0];
Assert.AreEqual("details-cmd-1", invokable.Id);
var result = invokable.Invoke(null);
Assert.AreEqual("details-cmd-1", invokedCommandId, "Invoking a details command should send command/invoke with the command id.");
Assert.AreEqual(CommandResultKind.KeepOpen, result.Kind);
}
[TestMethod]
public void ContextItems_ParseNestedMoreCommandsRecursively()
{

View File

@@ -69,8 +69,6 @@ Each JS extension runs in its own Node.js process. The host spawns the process,
---
---
## Known Gaps and Deferred Work
The following capabilities are intentionally not part of the current JS/TS extension surface. They are documented here so contributors know the boundary and the likely shape of a future solution. Each is deferred rather than rejected.

View File

@@ -27,7 +27,6 @@ tags, and command behavior match the C# sample as closely as the JS SDK allows:
- OnLoad demo.
- Icon page covering glyph, packaged file, first-party URL, and inline base64 sources.
- Slow loading list page.
- Prefix suggestions (`@` people, `/` commands).
- Content pages: mixed markdown plus form, plain text, image, and nested tree.
- Nested comments built from tree plus form content.
- Markdown pages: single block, many blocks, with details, and with images.
@@ -46,8 +45,8 @@ inventing protocol methods:
run protocol.
- Drag and drop via `DataPackage`. `IListItem` has no `DataPackage`, so the
clipboard demo copies to the clipboard instead.
- Details size (Small/Medium/Large). The JS `Details` type has no size, so the
variants collapse to the default.
- Toast icon and toast action button (`IToastArgs2`). `ToastArgs` carries a
message and an optional follow-up result only.
- Live-updating details through targeted property change. Approximated with a
dynamic page that refreshes items on a timer.
- Win32 foreground-window and other in-process host tricks.

View File

@@ -7,6 +7,13 @@ import type { CommandResult, Content, FormContent } from '@microsoft/cmdpal-sdk'
import { fileURLToPath } from 'node:url';
import { glyphIcon } from '../util.js';
/**
* Load this once so each content request can reuse the encoded image instead
* of reading the file or depending on the repo checkout.
*/
const localImagePath = fileURLToPath(new URL('../assets/hero.png', import.meta.url));
const localImage = await iconFromFile(localImagePath);
const loremIpsum =
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book. It has survived not only five centuries, but also the leap into electronic typesetting, remaining essentially unchanged. It was popularised in the 1960s with the release of sheets containing Lorem Ipsum passages, and more recently with desktop publishing software like Aldus PageMaker including versions of Lorem Ipsum.";
@@ -116,13 +123,10 @@ export class SampleImageContentPage extends ContentPageBase {
override icon = glyphIcon('\uE722');
override async getContent(): Promise<Content[]> {
const image = await iconFromFile(
fileURLToPath(new URL('../assets/hero.png', import.meta.url)),
);
override getContent(): Content[] {
return [
{ type: 'image', image },
{ type: 'image', image, maxWidth: 200, maxHeight: 200 },
{ type: 'image', image: localImage },
{ type: 'image', image: localImage, maxWidth: 200, maxHeight: 200 },
];
}
}

View File

@@ -7,17 +7,13 @@ import type { IListItem } from '@microsoft/cmdpal-sdk';
import { glyphIcon } from '../util.js';
/**
* A demo of clipboard integration. Mirrors the C# `SampleDataTransferPage`.
*
* Not supported yet: the C# page attaches a `DataPackage` to each list item to
* enable drag and drop (including delayed and image payloads). `IListItem` in
* the JS protocol has no `DataPackage`, so drag and drop is omitted and the
* text items expose a copy to clipboard command instead.
* A demo of clipboard integration. Mirrors the clipboard portion of the C#
* `SampleDataTransferPage`. Each item exposes a copy command.
*/
export class SampleDataTransferPage extends ListPageBase {
readonly id = 'sample-data-transfer-page';
readonly name = 'Open';
readonly title = 'Clipboard and Drag-and-Drop Demo';
readonly title = 'Clipboard Demo';
override icon = glyphIcon('\uE8C8');
@@ -26,7 +22,7 @@ export class SampleDataTransferPage extends ListPageBase {
new ListItemBase({
command: new CopyTextCommand('Text data in the Data Package', 'Copy text', 'Copied text'),
title: 'Item with plain text',
subtitle: 'Copy plain text to the clipboard (drag and drop is not supported from JS)',
subtitle: 'Copy plain text to the clipboard',
}),
new ListItemBase({
command: new CopyTextCommand(new Date().toLocaleString(), 'Copy timestamp', 'Copied timestamp'),

View File

@@ -116,8 +116,8 @@ function buildProgressButton(
* The hero image is a local asset that ships with the sample, so it renders
* without a network connection.
*
* Not supported yet: the JS `Details` type has no `Size` (Small/Medium/Large),
* so the C# size variants collapse into the single default size here.
* The details `size` field (small, medium, or large) controls how wide the
* details pane is; the metadata item below asks for a `large` pane.
*/
export class SampleListPageWithDetails extends ListPageBase {
readonly id = 'sample-list-page-with-details';
@@ -167,11 +167,12 @@ export class SampleListPageWithDetails extends ListPageBase {
new ListItemBase({
command: new NoOpCommand('details-metadata'),
title: 'This one has metadata',
subtitle: 'And a details panel',
subtitle: 'And a large details panel',
details: {
title: 'Metadata Example',
body: 'Each of the sections below is some sample metadata',
body: 'Each of the sections below is some sample metadata. This item asks for a `large` details pane.',
metadata: sampleMetadata(),
size: 'large',
},
}),
];

View File

@@ -7,17 +7,17 @@ import type { IListItem } from '@microsoft/cmdpal-sdk';
import { glyphIcon } from '../util.js';
/**
* A page that grows by one entry every time it is opened. Mirrors the intent of
* the C# `OnLoadPage`.
* A page that grows by one entry every time it is opened, demonstrating an
* OnLoad-style refresh. Mirrors the load side of the C# `OnLoadPage`.
*
* Approximation: the JS protocol exposes no page load/unload lifecycle events
* (the C# page hooks the `ItemsChanged` add/remove accessors). Here a "Loaded"
* entry is appended each time the host fetches the items, which happens on open.
* Approximation: the JS protocol exposes no explicit page load event, so a
* "Loaded" entry is appended each time the host fetches the items, which happens
* on open.
*/
export class OnLoadPage extends ListPageBase {
readonly id = 'on-load-page';
readonly name = 'Open';
readonly title = 'Load/Unload sample';
readonly title = 'OnLoad sample';
override icon = glyphIcon('\uE8AB');
override placeholderText = 'This page changes each time you load it';

View File

@@ -2,12 +2,29 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
import { ListItemBase, ListPageBase, NoOpCommand, Separator } from '@microsoft/cmdpal-sdk';
import {
iconFromFile,
ListItemBase,
ListPageBase,
NoOpCommand,
Separator,
} from '@microsoft/cmdpal-sdk';
import type { GridProperties, IListItem } from '@microsoft/cmdpal-sdk';
import { fileURLToPath } from 'node:url';
import { glyphIcon } from '../util.js';
let sectionPageCounter = 0;
/**
* Absolute path to the image asset that ships with the sample. Grid and gallery
* layouts show each item's icon prominently, so the section items below carry a
* committed local image the same way the C# `SampleListPageWithSections`
* assigns bundled images to every item. Resolving it from `import.meta.url`
* renders without a network fetch. See detailsPage.ts for the same pattern.
*/
const sectionImagePath = fileURLToPath(new URL('../assets/hero.png', import.meta.url));
const sectionImage = await iconFromFile(sectionImagePath);
/**
* A list (or grid) page that groups items under headings. The host only shows a
* heading for a `Separator` that carries a title, so each group is introduced by
@@ -15,6 +32,13 @@ let sectionPageCounter = 0;
* field (the host ignores `section` on command-bearing items). Mirrors the C#
* `SampleListPageWithSections`, whose `Section` objects become titled separators
* here.
*
* The host renders a titled `Separator` as heading text with no divider line and
* a plain `Separator` (no title) as a horizontal line with no text; that is the
* built-in behavior shared with the C# section pages. To give each group a
* visible divider, a plain `Separator` line is emitted between sections in
* addition to the titled heading. Every item also carries a local image icon so
* the grid and gallery variants show pictures rather than empty tiles.
*/
export class SampleListPageWithSections extends ListPageBase {
readonly id: string;
@@ -36,44 +60,54 @@ export class SampleListPageWithSections extends ListPageBase {
command: new NoOpCommand('sec1-a'),
title: 'Sample Title',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new Separator(),
new Separator('This is another section list'),
new ListItemBase({
command: new NoOpCommand('sec2-a'),
title: 'Another Title',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new ListItemBase({
command: new NoOpCommand('sec2-b'),
title: 'More Titles',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new ListItemBase({
command: new NoOpCommand('sec2-c'),
title: 'Stop With The Titles',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new Separator(),
new ListItemBase({
command: new NoOpCommand('sec-sep'),
title: 'Separators also work',
subtitle: "But I still don't do anything",
icon: sectionImage,
}),
new Separator(),
new Separator("There's another"),
new ListItemBase({
command: new NoOpCommand('sec3-a'),
title: 'Sample Title',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new ListItemBase({
command: new NoOpCommand('sec3-b'),
title: 'Another Title',
subtitle: "I don't do anything",
icon: sectionImage,
}),
new ListItemBase({
command: new NoOpCommand('sec3-c'),
title: 'More Titles',
subtitle: "I don't do anything",
icon: sectionImage,
}),
];
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
import { DynamicListPageBase, ListItemBase, NoOpCommand } from '@microsoft/cmdpal-sdk';
import type { IListItem } from '@microsoft/cmdpal-sdk';
import { glyphIcon } from '../util.js';
/**
* A demo of prefixed "nested" search suggestions. Mirrors the intent of the C#
* `SampleSuggestionsPage`.
*
* Approximation: the C# version tracks the caret position and wraps picked
* tokens in zero width spaces, and it uses `IExtendedAttributesProvider` to opt
* into token search. Neither the caret position nor extended attributes are
* exposed to JS extensions, so this sample keys off the last word of the query
* and uses `textToSuggest` to place a pick back into the search box.
*/
export class SampleSuggestionsPage extends DynamicListPageBase {
readonly id = 'sample-suggestions-page';
readonly name = 'Open';
readonly title = 'Sample prefixed search';
override icon = glyphIcon('\uE779');
override placeholderText = "Type a query, and use '@' to add a person";
override setSearchText(text: string): void {
this.searchText = text;
this.notifyItemsChanged();
}
override getItems(): IListItem[] {
const text = this.searchText ?? '';
const lastWord = text.split(/\s+/).pop() ?? '';
if (lastWord.startsWith('@')) {
return this.peopleItems(text, lastWord);
}
if (lastWord.startsWith('/')) {
return this.commandItems(text, lastWord);
}
if (text.length === 0) {
return [];
}
return [
new ListItemBase({
command: new NoOpCommand('suggestions-query'),
title: text,
subtitle: 'no tokens',
icon: glyphIcon('\uE8F2'),
}),
];
}
private peopleItems(fullText: string, prefixWord: string): IListItem[] {
const base = fullText.slice(0, fullText.length - prefixWord.length);
const items: IListItem[] = [];
for (let i = 1; i <= 5; i++) {
const name = `Person ${i}`;
items.push(
new ListItemBase({
command: new NoOpCommand(`suggestions-person-${i}`),
title: name,
subtitle: `Email: person${i}@example.com`,
textToSuggest: `${base}${name} `,
}),
);
}
return items;
}
private commandItems(fullText: string, prefixWord: string): IListItem[] {
const base = fullText.slice(0, fullText.length - prefixWord.length);
return [
new ListItemBase({
command: new NoOpCommand('suggestions-chat'),
title: '/chat',
subtitle: 'send a message',
textToSuggest: `${base}chat `,
}),
new ListItemBase({
command: new NoOpCommand('suggestions-status'),
title: '/status',
subtitle: 'set your status',
textToSuggest: `${base}status `,
}),
];
}
}

View File

@@ -16,7 +16,6 @@ import { SampleGridsListPage } from './pages/gridsPages.js';
import { OnLoadPage } from './pages/onLoadPage.js';
import { SampleIconPage } from './pages/iconPage.js';
import { SlowListPage } from './pages/slowListPage.js';
import { SampleSuggestionsPage } from './pages/suggestionsPage.js';
import {
SampleContentPage,
SampleImageContentPage,
@@ -100,8 +99,8 @@ export class SamplesListPage extends ListPageBase {
}),
new ListItemBase({
command: new OnLoadPage(),
title: 'Demo of OnLoad/OnUnload',
subtitle: 'Changes the list of items every time the page is opened / closed',
title: 'Demo of OnLoad',
subtitle: 'Changes the list of items every time the page is opened',
}),
new ListItemBase({
command: new SampleIconPage(),
@@ -113,11 +112,6 @@ export class SamplesListPage extends ListPageBase {
title: 'Slow loading list page',
subtitle: 'A demo of a list page that takes a while to load',
}),
new ListItemBase({
command: new SampleSuggestionsPage(),
title: 'Sample Prefix Suggestions',
subtitle: "A demo of using 'nested' pages to provide 'suggestions' as the user types",
}),
new ListItemBase({
command: new SampleContentPage(),
title: 'Sample content page',
@@ -172,8 +166,8 @@ export class SamplesListPage extends ListPageBase {
}),
new ListItemBase({
command: new SampleDataTransferPage(),
title: 'Clipboard and Drag-and-Drop Demo',
subtitle: 'Demonstrates clipboard integration and drag-and-drop functionality',
title: 'Clipboard Demo',
subtitle: 'Demonstrates clipboard integration',
}),
];
}

View File

@@ -214,6 +214,7 @@ export class WireSerializer {
assign(result, 'heroImage', details.heroImage ?? undefined);
assign(result, 'title', details.title);
assign(result, 'body', details.body);
assign(result, 'size', details.size);
if (details.metadata) {
result.metadata = details.metadata.map((element) => {

View File

@@ -31,6 +31,9 @@ export type ContentType = 'markdown' | 'form' | 'tree' | 'plainText' | 'image';
/** Layout used by a list page rendered as a grid. */
export type GridLayoutType = 'small' | 'medium' | 'gallery';
/** Size of the details pane shown alongside a list item or content page. */
export type DetailsSize = 'small' | 'medium' | 'large';
/** Font family used by plain text content. */
export type FontFamily = 'userInterface' | 'monospace';
@@ -345,6 +348,12 @@ export interface Details {
body?: string;
/** Labeled metadata rows shown below the body. */
metadata?: DetailsElement[];
/**
* Size of the details pane. Accepts the string names 'small', 'medium', or
* 'large', or the numeric `ContentSize` value the host uses (0, 1, 2).
* Defaults to 'small' when omitted.
*/
size?: DetailsSize | number;
}
// === Filters ===

View File

@@ -3,7 +3,7 @@
// See the LICENSE file in the project root for more information.
import { describe, expect, it } from 'vitest';
import type { ContextItem, IFallbackCommandItem, IListItem } from '../src/types.js';
import type { ContextItem, Details, IFallbackCommandItem, IListItem } from '../src/types.js';
import { WireSerializer } from '../src/runtime/serialize.js';
describe('WireSerializer.contextItems', () => {
@@ -71,6 +71,32 @@ describe('WireSerializer.contextItems', () => {
});
});
describe('WireSerializer.details', () => {
it('serializes a string size', () => {
const details: Details = { title: 'A', size: 'large' };
const wire = new WireSerializer().details(details);
expect(wire.size).toBe('large');
});
it('serializes a numeric ContentSize', () => {
const details: Details = { title: 'B', size: 1 };
const wire = new WireSerializer().details(details);
expect(wire.size).toBe(1);
});
it('omits size when it is not set', () => {
const details: Details = { title: 'C' };
const wire = new WireSerializer().details(details);
expect(wire).not.toHaveProperty('size');
});
});
describe('WireSerializer.listItem', () => {
it('serializes textToSuggest when set', () => {
const item: IListItem = {
@@ -118,3 +144,4 @@ describe('WireSerializer.commandItem fallback ids', () => {
expect(new WireSerializer().commandItem(item).id).toBe('fallback-command');
});
});