feat: Enhance A2UI with RadioGroup and Markdown support

- Added support for RadioGroup component in A2UI, allowing single selection from multiple options.
- Implemented Markdown parsing in A2UIPopupCard for better content rendering.
- Updated A2UIPopupCard to handle different question types and improved layout for multi-select and single-select questions.
- Introduced new utility functions for handling disabled items during installation.
- Enhanced installation process to restore previously disabled skills and commands.
- Updated notification store and related tests to accommodate new features.
- Adjusted Vite configuration for better development experience.
This commit is contained in:
catlog22
2026-02-04 13:45:47 +08:00
parent 1a05551d00
commit 341331325c
15 changed files with 743 additions and 178 deletions

View File

@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, appendFileSync } from 'fs';
import { existsSync, mkdirSync, readdirSync, statSync, copyFileSync, readFileSync, writeFileSync, unlinkSync, rmdirSync, appendFileSync, renameSync } from 'fs';
import { join, dirname, basename } from 'path';
import { homedir, platform } from 'os';
import { fileURLToPath } from 'url';
@@ -45,6 +45,145 @@ interface CopyResult {
directories: number;
}
// Disabled item tracking for install process
interface DisabledItem {
name: string;
path: string;
type: 'skill' | 'command';
}
interface DisabledItems {
skills: DisabledItem[];
commands: DisabledItem[];
}
/**
* Scan for disabled skills and commands before installation
* Skills: look for SKILL.md.disabled files
* Commands: look for *.md.disabled files
*/
function scanDisabledItems(installPath: string, globalPath?: string): DisabledItems {
const result: DisabledItems = { skills: [], commands: [] };
const pathsToScan = [installPath];
if (globalPath && globalPath !== installPath) {
pathsToScan.push(globalPath);
}
for (const basePath of pathsToScan) {
// Scan skills
const skillsDir = join(basePath, '.claude', 'skills');
if (existsSync(skillsDir)) {
try {
const entries = readdirSync(skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.isDirectory()) {
const disabledPath = join(skillsDir, entry.name, 'SKILL.md.disabled');
if (existsSync(disabledPath)) {
result.skills.push({
name: entry.name,
path: disabledPath,
type: 'skill'
});
}
}
}
} catch {
// Ignore errors
}
}
// Scan commands recursively
const commandsDir = join(basePath, '.claude', 'commands');
if (existsSync(commandsDir)) {
scanDisabledCommandsRecursive(commandsDir, commandsDir, result.commands);
}
}
return result;
}
/**
* Recursively scan for disabled command files
*/
function scanDisabledCommandsRecursive(baseDir: string, currentDir: string, results: DisabledItem[]): void {
try {
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
scanDisabledCommandsRecursive(baseDir, fullPath, results);
} else if (entry.isFile() && entry.name.endsWith('.md.disabled')) {
const relativePath = fullPath.substring(baseDir.length + 1);
const commandName = relativePath.replace(/\.disabled$/, '');
results.push({
name: commandName,
path: fullPath,
type: 'command'
});
}
}
} catch {
// Ignore errors
}
}
/**
* Restore disabled state after installation
* For each previously disabled item, if the enabled version exists, rename it back to disabled
*/
function restoreDisabledState(
disabledItems: DisabledItems,
installPath: string,
globalPath?: string
): { skillsRestored: number; commandsRestored: number } {
let skillsRestored = 0;
let commandsRestored = 0;
// Restore skills
for (const skill of disabledItems.skills) {
// Determine which path this skill belongs to
const skillDir = dirname(skill.path);
const enabledPath = join(skillDir, 'SKILL.md');
const disabledPath = join(skillDir, 'SKILL.md.disabled');
// If enabled version was installed, rename it to disabled
if (existsSync(enabledPath)) {
try {
// Remove old disabled file if it still exists (shouldn't, but be safe)
if (existsSync(disabledPath)) {
unlinkSync(disabledPath);
}
renameSync(enabledPath, disabledPath);
skillsRestored++;
} catch {
// Ignore errors
}
}
}
// Restore commands
for (const command of disabledItems.commands) {
const enabledPath = command.path.replace(/\.disabled$/, '');
const disabledPath = command.path;
// If enabled version was installed, rename it to disabled
if (existsSync(enabledPath)) {
try {
// Remove old disabled file if it still exists
if (existsSync(disabledPath)) {
unlinkSync(disabledPath);
}
renameSync(enabledPath, disabledPath);
commandsRestored++;
} catch {
// Ignore errors
}
}
}
return { skillsRestored, commandsRestored };
}
// Get package root directory (ccw/src/commands -> ccw)
function getPackageRoot(): string {
return join(__dirname, '..', '..');
@@ -204,6 +343,14 @@ export async function installCommand(options: InstallOptions): Promise<void> {
}
}
// Scan for disabled items before installation
const globalPath = mode === 'Path' ? homedir() : undefined;
const disabledItems = scanDisabledItems(installPath, globalPath);
const totalDisabled = disabledItems.skills.length + disabledItems.commands.length;
if (totalDisabled > 0) {
info(`Found ${totalDisabled} disabled items (${disabledItems.skills.length} skills, ${disabledItems.commands.length} commands)`);
}
// Create manifest
const manifest = createManifest(mode, installPath);
@@ -213,6 +360,7 @@ export async function installCommand(options: InstallOptions): Promise<void> {
let totalFiles = 0;
let totalDirs = 0;
let restoreStats = { skillsRestored: 0, commandsRestored: 0 };
try {
// For Path mode, install workflows to global first
@@ -259,6 +407,15 @@ export async function installCommand(options: InstallOptions): Promise<void> {
spinner.succeed('Installation complete!');
// Restore disabled state for previously disabled items
if (totalDisabled > 0) {
restoreStats = restoreDisabledState(disabledItems, installPath, globalPath);
const totalRestored = restoreStats.skillsRestored + restoreStats.commandsRestored;
if (totalRestored > 0) {
info(`Restored ${totalRestored} disabled items (${restoreStats.skillsRestored} skills, ${restoreStats.commandsRestored} commands)`);
}
}
} catch (err) {
spinner.fail('Installation failed');
const errMsg = err as Error;
@@ -290,6 +447,12 @@ export async function installCommand(options: InstallOptions): Promise<void> {
}
}
// Add restore stats if any disabled items were restored
if (restoreStats.skillsRestored > 0 || restoreStats.commandsRestored > 0) {
const totalRestored = restoreStats.skillsRestored + restoreStats.commandsRestored;
summaryLines.push(chalk.gray(`Disabled state restored: ${totalRestored} items`));
}
summaryLines.push('');
summaryLines.push(chalk.gray(`Manifest: ${basename(manifestPath)}`));

View File

@@ -59,6 +59,7 @@ export class A2UIWebSocketHandler {
}>();
private multiSelectSelections = new Map<string, Set<string>>();
private singleSelectSelections = new Map<string, string>();
private answerCallback?: (answer: QuestionAnswer) => boolean;
@@ -107,6 +108,10 @@ export class A2UIWebSocketHandler {
if (questionType === 'multi-select') {
// Selection state is updated via a2ui-action messages ("toggle") and resolved on "submit"
this.multiSelectSelections.set(questionId, new Set<string>());
} else if (questionType === 'select') {
// Single selection state is updated via a2ui-action messages ("select") and resolved on "submit"
// Initialize with empty string (no selection)
this.singleSelectSelections.set(questionId, '');
}
}
@@ -199,6 +204,7 @@ export class A2UIWebSocketHandler {
if (handled) {
this.activeSurfaces.delete(answer.questionId);
this.multiSelectSelections.delete(answer.questionId);
this.singleSelectSelections.delete(answer.questionId);
}
return handled;
@@ -223,6 +229,7 @@ export class A2UIWebSocketHandler {
if (handled) {
this.activeSurfaces.delete(questionId);
this.multiSelectSelections.delete(questionId);
this.singleSelectSelections.delete(questionId);
}
return handled;
};
@@ -242,6 +249,16 @@ export class A2UIWebSocketHandler {
return resolveAndCleanup({ questionId, value: value as string | boolean | string[], cancelled: false });
}
case 'select': {
// Single select: store the selected value (don't submit yet)
const value = params.value;
if (typeof value !== 'string') {
return false;
}
this.singleSelectSelections.set(questionId, value);
return true;
}
case 'toggle': {
const value = params.value;
const checked = params.checked;
@@ -261,8 +278,15 @@ export class A2UIWebSocketHandler {
}
case 'submit': {
const selected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(selected), cancelled: false });
// Check if this is a single-select or multi-select
const singleSelection = this.singleSelectSelections.get(questionId);
if (singleSelection !== undefined) {
// Single-select submit
return resolveAndCleanup({ questionId, value: singleSelection, cancelled: false });
}
// Multi-select submit
const multiSelected = this.multiSelectSelections.get(questionId) ?? new Set<string>();
return resolveAndCleanup({ questionId, value: Array.from(multiSelected), cancelled: false });
}
default:

View File

@@ -144,6 +144,7 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
surfaceId: string;
components: unknown[];
initialState: Record<string, unknown>;
displayMode?: 'popup' | 'panel';
};
} {
const components: unknown[] = [];
@@ -219,15 +220,43 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
const options = question.options?.map((opt) => ({
label: { literalString: opt.label },
value: opt.value,
description: opt.description ? { literalString: opt.description } : undefined,
})) || [];
// Use RadioGroup for direct selection display (not dropdown)
components.push({
id: 'select',
id: 'radio-group',
component: {
Dropdown: {
RadioGroup: {
options,
selectedValue: question.defaultValue ? { literalString: String(question.defaultValue) } : undefined,
onChange: { actionId: 'answer', parameters: { questionId: question.id } },
placeholder: question.placeholder || 'Select an option',
onChange: { actionId: 'select', parameters: { questionId: question.id } },
},
},
});
// Add Submit/Cancel buttons to avoid accidental submission
components.push({
id: 'submit-btn',
component: {
Button: {
onClick: { actionId: 'submit', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Submit' } },
},
variant: 'primary',
},
},
});
components.push({
id: 'cancel-btn',
component: {
Button: {
onClick: { actionId: 'cancel', parameters: { questionId: question.id } },
content: {
Text: { text: { literalString: 'Cancel' } },
},
variant: 'secondary',
},
},
});
@@ -239,21 +268,19 @@ function generateQuestionSurface(question: Question, surfaceId: string): {
label: { literalString: opt.label },
value: opt.value,
})) || [];
components.push({
id: 'checkboxes',
component: {
Card: {
content: options.map((opt, idx) => ({
id: `checkbox-${idx}`,
component: {
Checkbox: {
label: opt.label,
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
},
},
})),
// Add each checkbox as a separate component for better layout control
options.forEach((opt, idx) => {
components.push({
id: `checkbox-${idx}`,
component: {
Checkbox: {
label: opt.label,
onChange: { actionId: 'toggle', parameters: { questionId: question.id, value: opt.value } },
checked: { literalBoolean: false },
},
},
},
});
});
// Submit/cancel actions for multi-select so users can choose multiple options before resolving