mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-11 02:33:51 +08:00
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:
@@ -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)}`));
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user