Skip to content

Commit 90171a7

Browse files
sethjuarezCopilot
andcommitted
feat: show frontmatter in prompt preview
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Seth Juarez <me@sethjuarez.com>
1 parent eef5830 commit 90171a7

1 file changed

Lines changed: 349 additions & 0 deletions

File tree

vscode/prompty/packages/core/src/controllers/previewPanel.ts

Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ export class PreviewPanel implements Disposable {
163163
content: msg.toTextContent(),
164164
}));
165165
const rawJson = escapeHtml(JSON.stringify(wireMessages, null, 2));
166+
const frontmatterHtml = buildFrontmatterHtml(agent);
166167

167168
return /*html*/`<!DOCTYPE html>
168169
<html lang="en">
@@ -333,6 +334,136 @@ export class PreviewPanel implements Disposable {
333334
word-break: break-all;
334335
tab-size: 2;
335336
}
337+
.fm-grid {
338+
display: grid;
339+
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
340+
gap: 8px;
341+
margin-bottom: 12px;
342+
}
343+
.fm-card, .fm-section {
344+
border: 1px solid var(--vscode-panel-border);
345+
border-radius: 6px;
346+
background: color-mix(in srgb, var(--vscode-editorWidget-background) 70%, transparent);
347+
}
348+
.fm-card {
349+
padding: 10px 12px;
350+
}
351+
.fm-card-label, .fm-section h3 {
352+
color: var(--vscode-descriptionForeground);
353+
font-size: 11px;
354+
font-weight: 600;
355+
letter-spacing: 0.5px;
356+
text-transform: uppercase;
357+
}
358+
.fm-card-label {
359+
margin-bottom: 4px;
360+
}
361+
.fm-card-value {
362+
font-size: 13px;
363+
word-break: break-word;
364+
}
365+
.fm-section {
366+
margin-bottom: 12px;
367+
padding: 10px 12px;
368+
}
369+
.fm-section h3 {
370+
margin: 0 0 8px 0;
371+
}
372+
.fm-description {
373+
margin: 0 0 12px 0;
374+
color: var(--vscode-descriptionForeground);
375+
}
376+
.fm-table {
377+
width: 100%;
378+
border-collapse: collapse;
379+
font-size: 12px;
380+
}
381+
.fm-table th {
382+
text-align: left;
383+
color: var(--vscode-descriptionForeground);
384+
font-weight: 600;
385+
border-bottom: 1px solid var(--vscode-panel-border);
386+
padding: 4px 6px;
387+
}
388+
.fm-table td {
389+
border-bottom: 1px solid color-mix(in srgb, var(--vscode-panel-border) 60%, transparent);
390+
padding: 5px 6px;
391+
vertical-align: top;
392+
word-break: break-word;
393+
}
394+
.fm-table tr:last-child td { border-bottom: 0; }
395+
.fm-chip {
396+
display: inline-block;
397+
background: var(--vscode-badge-background);
398+
color: var(--vscode-badge-foreground);
399+
border-radius: 3px;
400+
padding: 1px 6px;
401+
margin: 0 4px 4px 0;
402+
font-size: 11px;
403+
}
404+
.fm-property-list {
405+
display: flex;
406+
flex-direction: column;
407+
gap: 8px;
408+
}
409+
.fm-property {
410+
border: 1px solid color-mix(in srgb, var(--vscode-panel-border) 75%, transparent);
411+
border-radius: 5px;
412+
padding: 8px 10px;
413+
background: color-mix(in srgb, var(--vscode-editor-background) 55%, transparent);
414+
}
415+
.fm-property-head {
416+
display: flex;
417+
align-items: center;
418+
flex-wrap: wrap;
419+
gap: 6px;
420+
margin-bottom: 5px;
421+
}
422+
.fm-property-name {
423+
font-weight: 600;
424+
font-size: 13px;
425+
}
426+
.fm-kind, .fm-required, .fm-optional {
427+
border-radius: 3px;
428+
padding: 1px 6px;
429+
font-size: 10px;
430+
text-transform: uppercase;
431+
letter-spacing: 0.4px;
432+
}
433+
.fm-kind {
434+
background: color-mix(in srgb, var(--vscode-textLink-foreground) 18%, transparent);
435+
color: var(--vscode-textLink-foreground);
436+
}
437+
.fm-required {
438+
background: color-mix(in srgb, var(--vscode-errorForeground) 14%, transparent);
439+
color: var(--vscode-errorForeground);
440+
}
441+
.fm-optional {
442+
background: color-mix(in srgb, var(--vscode-descriptionForeground) 14%, transparent);
443+
color: var(--vscode-descriptionForeground);
444+
}
445+
.fm-property-desc {
446+
color: var(--vscode-descriptionForeground);
447+
margin-bottom: 6px;
448+
}
449+
.fm-property-value {
450+
background: var(--vscode-textCodeBlock-background);
451+
border-radius: 4px;
452+
font-family: 'Cascadia Code', 'Fira Code', monospace;
453+
font-size: 12px;
454+
padding: 5px 7px;
455+
white-space: pre-wrap;
456+
word-break: break-word;
457+
}
458+
.fm-empty {
459+
color: var(--vscode-descriptionForeground);
460+
font-style: italic;
461+
}
462+
.fm-raw summary {
463+
cursor: pointer;
464+
color: var(--vscode-textLink-foreground);
465+
margin-bottom: 8px;
466+
}
336467
</style>
337468
</head>
338469
<body>
@@ -344,18 +475,23 @@ export class PreviewPanel implements Disposable {
344475
<div class="view-toggle">
345476
<button id="btn-rendered" class="active">Rendered</button>
346477
<button id="btn-raw">Raw JSON</button>
478+
<button id="btn-frontmatter">Frontmatter</button>
347479
</div>
348480
<div id="view-rendered">${messagesHtml}</div>
349481
<div id="view-raw" class="hidden"><pre class="raw-json">${rawJson}</pre></div>
482+
<div id="view-frontmatter" class="hidden">${frontmatterHtml}</div>
350483
<script nonce="${nonce}">
351484
function showView(view) {
352485
document.getElementById('view-rendered').className = view === 'rendered' ? '' : 'hidden';
486+
document.getElementById('view-frontmatter').className = view === 'frontmatter' ? '' : 'hidden';
353487
document.getElementById('view-raw').className = view === 'raw' ? '' : 'hidden';
354488
document.getElementById('btn-rendered').className = view === 'rendered' ? 'active' : '';
489+
document.getElementById('btn-frontmatter').className = view === 'frontmatter' ? 'active' : '';
355490
document.getElementById('btn-raw').className = view === 'raw' ? 'active' : '';
356491
}
357492
document.getElementById('btn-rendered').addEventListener('click', function() { showView('rendered'); });
358493
document.getElementById('btn-raw').addEventListener('click', function() { showView('raw'); });
494+
document.getElementById('btn-frontmatter').addEventListener('click', function() { showView('frontmatter'); });
359495
</script>
360496
</body>
361497
</html>`;
@@ -403,3 +539,216 @@ function escapeHtml(s: string): string {
403539
function truncate(s: string, max: number): string {
404540
return s.length > max ? s.slice(0, max) + '…' : s;
405541
}
542+
543+
function buildFrontmatterHtml(agent: Prompty): string {
544+
const model = agent.model;
545+
const connection = toRecord(model?.connection);
546+
const template = toRecord(agent.template);
547+
const format = toRecord(template.format);
548+
const parser = toRecord(template.parser);
549+
const metadata = agent.metadata ?? {};
550+
551+
const cards = [
552+
frontmatterCard('Name', agent.displayName || agent.name || 'unnamed'),
553+
frontmatterCard('Model', [model?.provider, model?.id].filter(Boolean).join(' - ') || 'not configured'),
554+
frontmatterCard('API', model?.apiType ?? 'chat'),
555+
frontmatterCard('Template', [format.kind, parser.kind].filter(Boolean).join(' -> ') || 'default'),
556+
frontmatterCard('Connection', describeConnection(connection)),
557+
];
558+
559+
const sections = [
560+
agent.description ? frontmatterSection('Description', `<p class="fm-description">${escapeHtml(agent.description)}</p>`) : '',
561+
propertySection('Inputs', agent.inputs ?? []),
562+
propertySection('Outputs', agent.outputs ?? []),
563+
keyValueSection('Model options', optionEntries(model?.options)),
564+
toolsSection(agent.tools ?? []),
565+
metadataSection(metadata),
566+
rawFrontmatterSection(agent),
567+
].filter(Boolean).join('\n');
568+
569+
return `<div class="fm-grid">${cards.join('\n')}</div>${sections}`;
570+
}
571+
572+
function frontmatterCard(label: string, value: unknown): string {
573+
return `<div class="fm-card">
574+
<div class="fm-card-label">${escapeHtml(label)}</div>
575+
<div class="fm-card-value">${escapeHtml(formatValue(value, 120))}</div>
576+
</div>`;
577+
}
578+
579+
function frontmatterSection(title: string, body: string): string {
580+
return `<section class="fm-section">
581+
<h3>${escapeHtml(title)}</h3>
582+
${body}
583+
</section>`;
584+
}
585+
586+
function propertySection(title: string, properties: {
587+
name?: string;
588+
kind?: string;
589+
required?: boolean;
590+
default?: unknown;
591+
example?: unknown;
592+
description?: string;
593+
enumValues?: unknown[];
594+
}[]): string {
595+
if (properties.length === 0) {
596+
return '';
597+
}
598+
const cards = properties.map((prop) => {
599+
const value = prop.default !== undefined ? prop.default : prop.example;
600+
const enumHtml = prop.enumValues && prop.enumValues.length > 0
601+
? `<div>${prop.enumValues.map(item => `<span class="fm-chip">${escapeHtml(formatValue(item, 40))}</span>`).join('')}</div>`
602+
: '';
603+
const valueHtml = value !== undefined
604+
? `<div class="fm-property-value">${escapeHtml(formatValue(value, 300))}</div>`
605+
: '';
606+
return `<div class="fm-property">
607+
<div class="fm-property-head">
608+
<span class="fm-property-name">${escapeHtml(prop.name || '(unnamed)')}</span>
609+
<span class="fm-kind">${escapeHtml(prop.kind || 'unknown')}</span>
610+
<span class="${prop.required ? 'fm-required' : 'fm-optional'}">${prop.required ? 'required' : 'optional'}</span>
611+
</div>
612+
${prop.description ? `<div class="fm-property-desc">${escapeHtml(prop.description)}</div>` : ''}
613+
${valueHtml}
614+
${enumHtml}
615+
</div>`;
616+
}).join('\n');
617+
618+
return frontmatterSection(title, `<div class="fm-property-list">${cards}</div>`);
619+
}
620+
621+
function keyValueSection(title: string, entries: [string, unknown][]): string {
622+
if (entries.length === 0) {
623+
return '';
624+
}
625+
const rows = entries.map(([key, value]) => `<tr>
626+
<td>${escapeHtml(key)}</td>
627+
<td>${escapeHtml(formatValue(value, 140))}</td>
628+
</tr>`).join('\n');
629+
return frontmatterSection(title, `<table class="fm-table">
630+
<thead><tr><th>Setting</th><th>Value</th></tr></thead>
631+
<tbody>${rows}</tbody>
632+
</table>`);
633+
}
634+
635+
function toolsSection(tools: { name?: string; kind?: string; description?: string; save?: () => Record<string, unknown> }[]): string {
636+
if (tools.length === 0) {
637+
return '';
638+
}
639+
const rows = tools.map((tool) => {
640+
const saved = saveObject(tool);
641+
const parameters = toRecord(saved.parameters);
642+
const parameterNames = Object.keys(toRecord(parameters.properties));
643+
const details = parameterNames.length > 0
644+
? parameterNames.join(', ')
645+
: Object.keys(saved).filter(k => !['name', 'kind', 'description'].includes(k)).join(', ');
646+
return `<tr>
647+
<td>${escapeHtml(tool.name || '(unnamed)')}</td>
648+
<td>${escapeHtml(tool.kind || 'unknown')}</td>
649+
<td>${escapeHtml(tool.description ?? '')}</td>
650+
<td>${escapeHtml(details || '')}</td>
651+
</tr>`;
652+
}).join('\n');
653+
return frontmatterSection('Tools', `<table class="fm-table">
654+
<thead><tr><th>Name</th><th>Kind</th><th>Description</th><th>Details</th></tr></thead>
655+
<tbody>${rows}</tbody>
656+
</table>`);
657+
}
658+
659+
function metadataSection(metadata: Record<string, unknown>): string {
660+
const entries = Object.entries(metadata);
661+
if (entries.length === 0) {
662+
return '';
663+
}
664+
const chipKeys = new Set(['authors', 'tags']);
665+
const chips = entries
666+
.filter(([key, value]) => chipKeys.has(key) && Array.isArray(value))
667+
.flatMap(([key, value]) => (value as unknown[]).map(item => `<span class="fm-chip">${escapeHtml(key)}: ${escapeHtml(String(item))}</span>`))
668+
.join('');
669+
const rows = entries
670+
.filter(([key]) => !chipKeys.has(key))
671+
.map(([key, value]) => `<tr><td>${escapeHtml(key)}</td><td>${escapeHtml(formatValue(value, 140))}</td></tr>`)
672+
.join('\n');
673+
const table = rows ? `<table class="fm-table"><tbody>${rows}</tbody></table>` : '';
674+
return frontmatterSection('Metadata', chips || table ? `${chips}${table}` : '<div class="fm-empty">No metadata</div>');
675+
}
676+
677+
function rawFrontmatterSection(agent: Prompty): string {
678+
const saved = redactSecrets(saveObject(agent));
679+
return frontmatterSection('Raw frontmatter', `<details class="fm-raw">
680+
<summary>Show normalized frontmatter JSON</summary>
681+
<pre class="raw-json">${escapeHtml(JSON.stringify(saved, null, 2))}</pre>
682+
</details>`);
683+
}
684+
685+
function optionEntries(options: unknown): [string, unknown][] {
686+
const saved = saveObject(options);
687+
const additionalProperties = toRecord(saved.additionalProperties);
688+
delete saved.additionalProperties;
689+
return [
690+
...Object.entries(saved),
691+
...Object.entries(additionalProperties).map(([key, value]) => [`additionalProperties.${key}`, value] as [string, unknown]),
692+
];
693+
}
694+
695+
function describeConnection(connection: Record<string, unknown>): string {
696+
if (Object.keys(connection).length === 0) {
697+
return 'not configured';
698+
}
699+
const parts = [connection.kind, connection.name, connection.endpoint, connection.target]
700+
.filter((value): value is string => typeof value === 'string' && value.length > 0);
701+
return parts.join(' - ') || 'configured';
702+
}
703+
704+
function formatValue(value: unknown, max: number): string {
705+
if (value === undefined || value === null) {
706+
return '';
707+
}
708+
if (typeof value === 'string') {
709+
return truncate(value, max);
710+
}
711+
if (typeof value === 'number' || typeof value === 'boolean') {
712+
return String(value);
713+
}
714+
return truncate(JSON.stringify(redactSecrets(value)), max);
715+
}
716+
717+
function saveObject(value: unknown): Record<string, unknown> {
718+
if (!value || typeof value !== 'object') {
719+
return {};
720+
}
721+
const maybeSave = (value as { save?: () => Record<string, unknown> }).save;
722+
if (typeof maybeSave === 'function') {
723+
return maybeSave.call(value);
724+
}
725+
return { ...(value as Record<string, unknown>) };
726+
}
727+
728+
function toRecord(value: unknown): Record<string, unknown> {
729+
return saveObject(value);
730+
}
731+
732+
function redactSecrets(value: unknown): unknown {
733+
if (Array.isArray(value)) {
734+
return value.map(redactSecrets);
735+
}
736+
if (!value || typeof value !== 'object') {
737+
return value;
738+
}
739+
const result: Record<string, unknown> = {};
740+
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
741+
result[key] = isSensitiveKey(key) ? '[redacted]' : redactSecrets(item);
742+
}
743+
return result;
744+
}
745+
746+
function isSensitiveKey(key: string): boolean {
747+
const normalized = key.toLowerCase();
748+
return normalized.includes('apikey')
749+
|| normalized.includes('api_key')
750+
|| normalized.includes('secret')
751+
|| normalized.includes('password')
752+
|| normalized.includes('token')
753+
|| normalized.includes('credential');
754+
}

0 commit comments

Comments
 (0)