From 34a6a4bea21150cb2b82cf707f97f8f4d97df853 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:22:59 +0900 Subject: [PATCH] Squashed commit of the following: commit 2730269da717c78e90bc7d35ea6b2404f8e845f5 Author: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri Feb 6 00:11:54 2026 +0900 takt: add-category-display-split --- package-lock.json | 4 +- src/__tests__/piece-category-config.test.ts | 201 ++++++++++++++++++++ src/features/pieceSelection/index.ts | 2 +- src/infra/config/global/index.ts | 1 + src/infra/config/global/pieceCategories.ts | 8 + src/infra/config/loaders/pieceCategories.ts | 106 +++++++++-- 6 files changed, 308 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ff5b6ab..b4b3fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "takt", - "version": "0.6.0-rc1", + "version": "0.6.0-rc2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "takt", - "version": "0.6.0-rc1", + "version": "0.6.0-rc2", "license": "MIT", "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.2.19", diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 6d4deb5..510c24d 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -36,6 +36,7 @@ const pieceCategoriesState = vi.hoisted(() => ({ categories: undefined as any, showOthersCategory: undefined as boolean | undefined, othersCategoryName: undefined as string | undefined, + builtinCategoryName: undefined as string | undefined, })); vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { @@ -53,6 +54,7 @@ vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => { getPieceCategoriesConfig: () => pieceCategoriesState.categories, getShowOthersCategory: () => pieceCategoriesState.showOthersCategory, getOthersCategoryName: () => pieceCategoriesState.othersCategoryName, + getBuiltinCategoryName: () => pieceCategoriesState.builtinCategoryName, }; }); @@ -105,6 +107,7 @@ describe('piece category config loading', () => { pieceCategoriesState.categories = undefined; pieceCategoriesState.showOthersCategory = undefined; pieceCategoriesState.othersCategoryName = undefined; + pieceCategoriesState.builtinCategoryName = undefined; }); afterEach(() => { @@ -126,6 +129,7 @@ others_category_name: "Others" expect(config!.pieceCategories).toEqual([ { name: 'Default', pieces: ['simple'], children: [] }, ]); + expect(config!.hasCustomCategories).toBe(false); }); it('should prefer project config over default when piece_categories is defined', () => { @@ -150,6 +154,7 @@ show_others_category: false { name: 'Project', pieces: ['custom'], children: [] }, ]); expect(config!.showOthersCategory).toBe(false); + expect(config!.hasCustomCategories).toBe(true); }); it('should prefer user config over project config when piece_categories is defined', () => { @@ -179,6 +184,7 @@ piece_categories: expect(config!.pieceCategories).toEqual([ { name: 'User', pieces: ['preferred'], children: [] }, ]); + expect(config!.hasCustomCategories).toBe(true); }); it('should ignore configs without piece_categories and fall back to default', () => { @@ -223,6 +229,8 @@ describe('buildCategorizedPieces', () => { ], showOthersCategory: true, othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: false, }; const categorized = buildCategorizedPieces(allPieces, config); @@ -236,6 +244,7 @@ describe('buildCategorizedPieces', () => { expect(categorized.missingPieces).toEqual([ { categoryPath: ['Cat'], pieceName: 'missing' }, ]); + expect(categorized.builtinCategoryName).toBe('Builtin'); }); it('should skip empty categories', () => { @@ -248,6 +257,8 @@ describe('buildCategorizedPieces', () => { ], showOthersCategory: false, othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: false, }; const categorized = buildCategorizedPieces(allPieces, config); @@ -280,3 +291,193 @@ describe('buildCategorizedPieces', () => { expect(paths).toEqual(['Parent / Child']); }); }); + +describe('buildCategorizedPieces with hasCustomCategories (auto builtin categorization)', () => { + let testDir: string; + let resourcesDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); + resourcesDir = join(testDir, 'resources'); + + mkdirSync(resourcesDir, { recursive: true }); + pathsState.resourcesDir = resourcesDir; + + pieceCategoriesState.categories = undefined; + pieceCategoriesState.showOthersCategory = undefined; + pieceCategoriesState.othersCategoryName = undefined; + pieceCategoriesState.builtinCategoryName = undefined; + }); + + afterEach(() => { + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should auto-categorize uncategorized builtins when hasCustomCategories is true', () => { + // Set up default categories for auto-categorization + writeYaml(join(resourcesDir, 'default-categories.yaml'), ` +piece_categories: + Standard: + pieces: + - default + - minimal + Advanced: + pieces: + - research +`); + + const allPieces = createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + { name: 'minimal', source: 'builtin' }, + { name: 'research', source: 'builtin' }, + ]); + + // User only categorizes their own piece + const config = { + pieceCategories: [ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: true, + }; + + const categorized = buildCategorizedPieces(allPieces, config); + + // User pieces in categories + expect(categorized.categories).toEqual([ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + ]); + + // Builtins auto-categorized using default category structure + expect(categorized.builtinCategories).toEqual([ + { name: 'Standard', pieces: ['default', 'minimal'], children: [] }, + { name: 'Advanced', pieces: ['research'], children: [] }, + ]); + }); + + it('should not duplicate builtins that are explicitly in user categories', () => { + writeYaml(join(resourcesDir, 'default-categories.yaml'), ` +piece_categories: + Standard: + pieces: + - default + - minimal +`); + + const allPieces = createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + { name: 'minimal', source: 'builtin' }, + ]); + + // User explicitly includes 'default' in their category + const config = { + pieceCategories: [ + { name: 'My Favorites', pieces: ['my-piece', 'default'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: true, + }; + + const categorized = buildCategorizedPieces(allPieces, config); + + // 'default' is in user-defined builtin categories (from user's category config) + expect(categorized.builtinCategories).toEqual([ + { name: 'My Favorites', pieces: ['default'], children: [] }, + // 'minimal' auto-categorized from default categories + { name: 'Standard', pieces: ['minimal'], children: [] }, + ]); + }); + + it('should ensure builtins are visible even with showOthersCategory: false', () => { + writeYaml(join(resourcesDir, 'default-categories.yaml'), ` +piece_categories: + Standard: + pieces: + - default +`); + + const allPieces = createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + { name: 'extra-builtin', source: 'builtin' }, + ]); + + const config = { + pieceCategories: [ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: true, + }; + + const categorized = buildCategorizedPieces(allPieces, config); + + // Both builtins should be in builtinCategories, never hidden + expect(categorized.builtinCategories).toEqual([ + { name: 'Standard', pieces: ['default'], children: [] }, + // extra-builtin not in default categories, so flat under Builtin + { name: 'Builtin', pieces: ['extra-builtin'], children: [] }, + ]); + }); + + it('should use custom builtinCategoryName when configured', () => { + const allPieces = createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + ]); + + // No default categories file — builtins go to flat list + const config = { + pieceCategories: [ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + builtinCategoryName: 'System Pieces', + hasCustomCategories: true, + }; + + const categorized = buildCategorizedPieces(allPieces, config); + + expect(categorized.builtinCategoryName).toBe('System Pieces'); + // Flat fallback uses the custom name + expect(categorized.builtinCategories).toEqual([ + { name: 'System Pieces', pieces: ['default'], children: [] }, + ]); + }); + + it('should fall back to flat Builtin category when default categories are unavailable', () => { + // No default-categories.yaml file + + const allPieces = createPieceMap([ + { name: 'my-piece', source: 'user' }, + { name: 'default', source: 'builtin' }, + { name: 'minimal', source: 'builtin' }, + ]); + + const config = { + pieceCategories: [ + { name: 'My Pieces', pieces: ['my-piece'], children: [] }, + ], + showOthersCategory: false, + othersCategoryName: 'Others', + builtinCategoryName: 'Builtin', + hasCustomCategories: true, + }; + + const categorized = buildCategorizedPieces(allPieces, config); + + // All builtins in a flat 'Builtin' category + expect(categorized.builtinCategories).toEqual([ + { name: 'Builtin', pieces: ['default', 'minimal'], children: [] }, + ]); + }); +}); diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 5bb6359..f112f52 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -366,7 +366,7 @@ async function selectTopLevelPieceOption( // 4. Builtin pieces if (builtinCount > 0) { options.push({ - label: `📂 Builtin/ (${builtinCount})`, + label: `📂 ${categorized.builtinCategoryName}/ (${builtinCount})`, value: BUILTIN_SOURCE_VALUE, }); } diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index f866866..c879bb3 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -32,6 +32,7 @@ export { setShowOthersCategory, getOthersCategoryName, setOthersCategoryName, + getBuiltinCategoryName, } from './pieceCategories.js'; export { diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index 4bc818f..0afe2b1 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -15,6 +15,7 @@ interface PieceCategoriesFile { categories?: PieceCategoryConfigNode; show_others_category?: boolean; others_category_name?: string; + builtin_category_name?: string; } function getDefaultPieceCategoriesPath(): string { @@ -100,3 +101,10 @@ export function setOthersCategoryName(name: string): void { data.others_category_name = name; savePieceCategoriesFile(data); } + +/** Get builtin category name */ +export function getBuiltinCategoryName(): string | undefined { + const data = loadPieceCategoriesFile(); + return data.builtin_category_name; +} + diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index ad2277b..a62ff40 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -12,6 +12,7 @@ import { getPieceCategoriesConfig, getShowOthersCategory, getOthersCategoryName, + getBuiltinCategoryName, } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; @@ -33,11 +34,15 @@ export interface CategoryConfig { pieceCategories: PieceCategoryNode[]; showOthersCategory: boolean; othersCategoryName: string; + builtinCategoryName: string; + /** True when categories are from user or project config (not builtin defaults). Triggers auto-categorization of builtins. */ + hasCustomCategories: boolean; } export interface CategorizedPieces { categories: PieceCategoryNode[]; builtinCategories: PieceCategoryNode[]; + builtinCategoryName: string; allPieces: Map; missingPieces: MissingPiece[]; } @@ -134,6 +139,8 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel), showOthersCategory, othersCategoryName, + builtinCategoryName: 'Builtin', + hasCustomCategories: false, }; } @@ -166,16 +173,19 @@ export function getPieceCategories(cwd: string): CategoryConfig | null { if (userCategoriesNode) { const showOthersCategory = getShowOthersCategory() ?? true; const othersCategoryName = getOthersCategoryName() ?? 'Others'; + const builtinCategoryName = getBuiltinCategoryName() ?? 'Builtin'; return { pieceCategories: parseCategoryTree(userCategoriesNode, 'user config'), showOthersCategory, othersCategoryName, + builtinCategoryName, + hasCustomCategories: true, }; } const projectConfig = loadCategoryConfigFromPath(getProjectConfigPath(cwd), 'project config'); if (projectConfig) { - return projectConfig; + return { ...projectConfig, hasCustomCategories: true }; } return loadDefaultCategories(); @@ -211,12 +221,14 @@ function buildCategoryTreeForSource( allPieces: Map, sourceFilter: (source: PieceSource) => boolean, categorized: Set, + allowedPieces?: Set, ): PieceCategoryNode[] { const result: PieceCategoryNode[] = []; for (const node of categories) { const pieces: string[] = []; for (const pieceName of node.pieces) { + if (allowedPieces && !allowedPieces.has(pieceName)) continue; const entry = allPieces.get(pieceName); if (!entry) continue; if (!sourceFilter(entry.source)) continue; @@ -224,7 +236,7 @@ function buildCategoryTreeForSource( categorized.add(pieceName); } - const children = buildCategoryTreeForSource(node.children, allPieces, sourceFilter, categorized); + const children = buildCategoryTreeForSource(node.children, allPieces, sourceFilter, categorized, allowedPieces); if (pieces.length > 0 || children.length > 0) { result.push({ name: node.name, pieces, children }); } @@ -258,6 +270,58 @@ function appendOthersCategory( return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }]; } +/** + * Collect uncategorized builtin piece names. + */ +function collectUncategorizedBuiltins( + allPieces: Map, + categorizedBuiltin: Set, +): Set { + const uncategorized = new Set(); + for (const [pieceName, entry] of allPieces.entries()) { + if (entry.source === 'builtin' && !categorizedBuiltin.has(pieceName)) { + uncategorized.add(pieceName); + } + } + return uncategorized; +} + +/** + * Build builtin categories for uncategorized builtins using default category structure. + * Falls back to flat list if default categories are unavailable. + */ +function buildAutoBuiltinCategories( + allPieces: Map, + uncategorizedBuiltins: Set, + builtinCategoryName: string, + defaultConfig: CategoryConfig | null, +): PieceCategoryNode[] { + if (defaultConfig) { + const autoCategorized = new Set(); + const autoCategories = buildCategoryTreeForSource( + defaultConfig.pieceCategories, + allPieces, + (source) => source === 'builtin', + autoCategorized, + uncategorizedBuiltins, + ); + // Any builtins still not categorized by default categories go into a flat list + const remaining: string[] = []; + for (const name of uncategorizedBuiltins) { + if (!autoCategorized.has(name)) { + remaining.push(name); + } + } + if (remaining.length > 0) { + autoCategories.push({ name: builtinCategoryName, pieces: remaining, children: [] }); + } + return autoCategories; + } + + // No default categories available: flat list + return [{ name: builtinCategoryName, pieces: Array.from(uncategorizedBuiltins), children: [] }]; +} + /** * Build categorized pieces map from configuration. */ @@ -265,6 +329,8 @@ export function buildCategorizedPieces( allPieces: Map, config: CategoryConfig, ): CategorizedPieces { + const { builtinCategoryName } = config; + const ignoreMissing = new Set(); if (!getBuiltinPiecesEnabled()) { for (const name of listBuiltinPieceNames({ includeDisabled: true })) { @@ -301,6 +367,16 @@ export function buildCategorizedPieces( categorizedBuiltin, ); + // When user defined categories, auto-categorize uncategorized builtins + if (config.hasCustomCategories) { + const uncategorizedBuiltins = collectUncategorizedBuiltins(allPieces, categorizedBuiltin); + if (uncategorizedBuiltins.size > 0) { + const defaultConfig = loadDefaultCategories(); + const autoCategories = buildAutoBuiltinCategories(allPieces, uncategorizedBuiltins, builtinCategoryName, defaultConfig); + builtinCategories.push(...autoCategories); + } + } + const finalCategories = config.showOthersCategory ? appendOthersCategory( categories, @@ -311,19 +387,27 @@ export function buildCategorizedPieces( ) : categories; - const finalBuiltinCategories = config.showOthersCategory - ? appendOthersCategory( - builtinCategories, - allPieces, - categorizedBuiltin, - isBuiltin, - config.othersCategoryName, - ) - : builtinCategories; + // For user-defined configs, uncategorized builtins are already handled above, + // so only apply Others for non-user-defined configs + let finalBuiltinCategories: PieceCategoryNode[]; + if (config.hasCustomCategories) { + finalBuiltinCategories = builtinCategories; + } else { + finalBuiltinCategories = config.showOthersCategory + ? appendOthersCategory( + builtinCategories, + allPieces, + categorizedBuiltin, + isBuiltin, + config.othersCategoryName, + ) + : builtinCategories; + } return { categories: finalCategories, builtinCategories: finalBuiltinCategories, + builtinCategoryName, allPieces, missingPieces, };