カテゴリ設定を簡素化: 自動コピー方式に変更

ユーザー/ビルトインの分離を廃止し、単一のカテゴリツリーに統一。
~/.takt/preferences/piece-categories.yaml を唯一のソースとし、
ファイルがなければ builtin デフォルトから自動コピーする。

- builtinCategories 分離と「📂 Builtin/」フォルダ表示を廃止
- appendOthersCategory で同名カテゴリへの未分類 piece マージを修正
- takt reset categories コマンドを追加
- default-categories.yaml を piece-categories.yaml にリネーム
This commit is contained in:
nrslib 2026-02-06 01:24:31 +09:00
parent 34a6a4bea2
commit 68b45abbf6
14 changed files with 365 additions and 732 deletions

View File

@ -2,6 +2,7 @@ piece_categories:
"🚀 クイックスタート": "🚀 クイックスタート":
pieces: pieces:
- default - default
- coding
- minimal - minimal
"🔍 レビュー&修正": "🔍 レビュー&修正":

View File

@ -3,24 +3,22 @@
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { mkdirSync, rmSync, writeFileSync, existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { PieceWithSource } from '../infra/config/index.js'; import type { PieceWithSource } from '../infra/config/index.js';
const pathsState = vi.hoisted(() => ({ const pathsState = vi.hoisted(() => ({
globalConfigPath: '',
projectConfigPath: '',
resourcesDir: '', resourcesDir: '',
userCategoriesPath: '',
})); }));
vi.mock('../infra/config/paths.js', async (importOriginal) => { vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>; const original = await importOriginal() as Record<string, unknown>;
return { return {
...original, ...original,
getGlobalConfigPath: () => pathsState.globalConfigPath, getLanguage: () => 'en',
getProjectConfigPath: () => pathsState.projectConfigPath,
}; };
}); });
@ -32,29 +30,9 @@ vi.mock('../infra/resources/index.js', async (importOriginal) => {
}; };
}); });
const pieceCategoriesState = vi.hoisted(() => ({ vi.mock('../infra/config/global/pieceCategories.js', async () => {
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) => {
const original = await importOriginal() as Record<string, unknown>;
return { return {
...original, ensureUserCategoriesFile: () => pathsState.userCategoriesPath,
getLanguage: () => 'en',
};
});
vi.mock('../infra/config/global/pieceCategories.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
return {
...original,
getPieceCategoriesConfig: () => pieceCategoriesState.categories,
getShowOthersCategory: () => pieceCategoriesState.showOthersCategory,
getOthersCategoryName: () => pieceCategoriesState.othersCategoryName,
getBuiltinCategoryName: () => pieceCategoriesState.builtinCategoryName,
}; };
}); });
@ -89,33 +67,22 @@ function createPieceMap(entries: { name: string; source: 'builtin' | 'user' | 'p
describe('piece category config loading', () => { describe('piece category config loading', () => {
let testDir: string; let testDir: string;
let resourcesDir: string; let resourcesDir: string;
let globalConfigPath: string;
let projectConfigPath: string;
beforeEach(() => { beforeEach(() => {
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`);
resourcesDir = join(testDir, 'resources'); resourcesDir = join(testDir, 'resources');
globalConfigPath = join(testDir, 'global-config.yaml');
projectConfigPath = join(testDir, 'project-config.yaml');
mkdirSync(resourcesDir, { recursive: true }); mkdirSync(resourcesDir, { recursive: true });
pathsState.globalConfigPath = globalConfigPath;
pathsState.projectConfigPath = projectConfigPath;
pathsState.resourcesDir = resourcesDir; pathsState.resourcesDir = resourcesDir;
// Reset piece categories state
pieceCategoriesState.categories = undefined;
pieceCategoriesState.showOthersCategory = undefined;
pieceCategoriesState.othersCategoryName = undefined;
pieceCategoriesState.builtinCategoryName = undefined;
}); });
afterEach(() => { afterEach(() => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
it('should load default categories when no configs define piece_categories', () => { it('should load categories from user file (auto-copied from default)', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` const userPath = join(testDir, 'piece-categories.yaml');
writeYaml(userPath, `
piece_categories: piece_categories:
Default: Default:
pieces: pieces:
@ -123,86 +90,51 @@ piece_categories:
show_others_category: true show_others_category: true
others_category_name: "Others" others_category_name: "Others"
`); `);
pathsState.userCategoriesPath = userPath;
const config = getPieceCategories(testDir); const config = getPieceCategories();
expect(config).not.toBeNull(); expect(config).not.toBeNull();
expect(config!.pieceCategories).toEqual([ expect(config!.pieceCategories).toEqual([
{ name: 'Default', pieces: ['simple'], children: [] }, { name: 'Default', pieces: ['simple'], children: [] },
]); ]);
expect(config!.hasCustomCategories).toBe(false); expect(config!.showOthersCategory).toBe(true);
expect(config!.othersCategoryName).toBe('Others');
}); });
it('should prefer project config over default when piece_categories is defined', () => { it('should return null when user file has no piece_categories', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` const userPath = join(testDir, 'piece-categories.yaml');
piece_categories: writeYaml(userPath, `
Default: show_others_category: true
pieces:
- simple
`); `);
pathsState.userCategoriesPath = userPath;
writeYaml(projectConfigPath, ` const config = getPieceCategories();
expect(config).toBeNull();
});
it('should parse nested categories from user file', () => {
const userPath = join(testDir, 'piece-categories.yaml');
writeYaml(userPath, `
piece_categories: piece_categories:
Project: Parent:
pieces: pieces:
- custom - parent-piece
show_others_category: false Child:
pieces:
- child-piece
`); `);
pathsState.userCategoriesPath = userPath;
const config = getPieceCategories(testDir); const config = getPieceCategories();
expect(config).not.toBeNull(); expect(config).not.toBeNull();
expect(config!.pieceCategories).toEqual([ expect(config!.pieceCategories).toEqual([
{ name: 'Project', pieces: ['custom'], children: [] }, {
]); name: 'Parent',
expect(config!.showOthersCategory).toBe(false); pieces: ['parent-piece'],
expect(config!.hasCustomCategories).toBe(true); children: [
}); { name: 'Child', pieces: ['child-piece'], children: [] },
],
it('should prefer user config over project config when piece_categories is defined', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
piece_categories:
Default:
pieces:
- simple
`);
writeYaml(projectConfigPath, `
piece_categories:
Project:
pieces:
- custom
`);
// Simulate user config from separate file
pieceCategoriesState.categories = {
User: {
pieces: ['preferred'],
}, },
};
const config = getPieceCategories(testDir);
expect(config).not.toBeNull();
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', () => {
writeYaml(join(resourcesDir, 'default-categories.yaml'), `
piece_categories:
Default:
pieces:
- simple
`);
writeYaml(globalConfigPath, `
show_others_category: false
`);
const config = getPieceCategories(testDir);
expect(config).not.toBeNull();
expect(config!.pieceCategories).toEqual([
{ name: 'Default', pieces: ['simple'], children: [] },
]); ]);
}); });
@ -210,10 +142,25 @@ show_others_category: false
const config = loadDefaultCategories(); const config = loadDefaultCategories();
expect(config).toBeNull(); expect(config).toBeNull();
}); });
it('should load default categories from resources', () => {
writeYaml(join(resourcesDir, 'piece-categories.yaml'), `
piece_categories:
Quick Start:
pieces:
- default
`);
const config = loadDefaultCategories();
expect(config).not.toBeNull();
expect(config!.pieceCategories).toEqual([
{ name: 'Quick Start', pieces: ['default'], children: [] },
]);
});
}); });
describe('buildCategorizedPieces', () => { describe('buildCategorizedPieces', () => {
it('should warn for missing pieces and generate Others', () => { it('should place all pieces (user and builtin) into a unified category tree', () => {
const allPieces = createPieceMap([ const allPieces = createPieceMap([
{ name: 'a', source: 'user' }, { name: 'a', source: 'user' },
{ name: 'b', source: 'user' }, { name: 'b', source: 'user' },
@ -221,30 +168,20 @@ describe('buildCategorizedPieces', () => {
]); ]);
const config = { const config = {
pieceCategories: [ pieceCategories: [
{ { name: 'Cat', pieces: ['a', 'missing', 'c'], children: [] },
name: 'Cat',
pieces: ['a', 'missing', 'c'],
children: [],
},
], ],
showOthersCategory: true, showOthersCategory: true,
othersCategoryName: 'Others', othersCategoryName: 'Others',
builtinCategoryName: 'Builtin',
hasCustomCategories: false,
}; };
const categorized = buildCategorizedPieces(allPieces, config); const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([ expect(categorized.categories).toEqual([
{ name: 'Cat', pieces: ['a'], children: [] }, { name: 'Cat', pieces: ['a', 'c'], children: [] },
{ name: 'Others', pieces: ['b'], children: [] }, { name: 'Others', pieces: ['b'], children: [] },
]); ]);
expect(categorized.builtinCategories).toEqual([
{ name: 'Cat', pieces: ['c'], children: [] },
]);
expect(categorized.missingPieces).toEqual([ expect(categorized.missingPieces).toEqual([
{ categoryPath: ['Cat'], pieceName: 'missing' }, { categoryPath: ['Cat'], pieceName: 'missing' },
]); ]);
expect(categorized.builtinCategoryName).toBe('Builtin');
}); });
it('should skip empty categories', () => { it('should skip empty categories', () => {
@ -257,13 +194,71 @@ describe('buildCategorizedPieces', () => {
], ],
showOthersCategory: false, showOthersCategory: false,
othersCategoryName: 'Others', othersCategoryName: 'Others',
builtinCategoryName: 'Builtin',
hasCustomCategories: false,
}; };
const categorized = buildCategorizedPieces(allPieces, config); const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([]); expect(categorized.categories).toEqual([]);
expect(categorized.builtinCategories).toEqual([]); });
it('should append Others category for uncategorized pieces', () => {
const allPieces = createPieceMap([
{ name: 'default', source: 'builtin' },
{ name: 'extra', source: 'builtin' },
]);
const config = {
pieceCategories: [
{ name: 'Main', pieces: ['default'], children: [] },
],
showOthersCategory: true,
othersCategoryName: 'Others',
};
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
{ name: 'Main', pieces: ['default'], children: [] },
{ name: 'Others', pieces: ['extra'], children: [] },
]);
});
it('should merge uncategorized pieces into existing Others category', () => {
const allPieces = createPieceMap([
{ name: 'default', source: 'builtin' },
{ name: 'extra', source: 'builtin' },
{ name: 'user-piece', source: 'user' },
]);
const config = {
pieceCategories: [
{ name: 'Main', pieces: ['default'], children: [] },
{ name: 'Others', pieces: ['extra'], children: [] },
],
showOthersCategory: true,
othersCategoryName: 'Others',
};
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
{ name: 'Main', pieces: ['default'], children: [] },
{ name: 'Others', pieces: ['extra', 'user-piece'], children: [] },
]);
});
it('should not append Others when showOthersCategory is false', () => {
const allPieces = createPieceMap([
{ name: 'default', source: 'builtin' },
{ name: 'extra', source: 'builtin' },
]);
const config = {
pieceCategories: [
{ name: 'Main', pieces: ['default'], children: [] },
],
showOthersCategory: false,
othersCategoryName: 'Others',
};
const categorized = buildCategorizedPieces(allPieces, config);
expect(categorized.categories).toEqual([
{ name: 'Main', pieces: ['default'], children: [] },
]);
}); });
it('should find categories containing a piece', () => { it('should find categories containing a piece', () => {
@ -292,192 +287,24 @@ describe('buildCategorizedPieces', () => {
}); });
}); });
describe('buildCategorizedPieces with hasCustomCategories (auto builtin categorization)', () => { describe('ensureUserCategoriesFile (integration)', () => {
let testDir: string; let testDir: string;
let resourcesDir: string;
beforeEach(() => { beforeEach(() => {
testDir = join(tmpdir(), `takt-cat-config-${randomUUID()}`); testDir = join(tmpdir(), `takt-cat-ensure-${randomUUID()}`);
resourcesDir = join(testDir, 'resources'); mkdirSync(testDir, { recursive: true });
mkdirSync(resourcesDir, { recursive: true });
pathsState.resourcesDir = resourcesDir;
pieceCategoriesState.categories = undefined;
pieceCategoriesState.showOthersCategory = undefined;
pieceCategoriesState.othersCategoryName = undefined;
pieceCategoriesState.builtinCategoryName = undefined;
}); });
afterEach(() => { afterEach(() => {
rmSync(testDir, { recursive: true, force: true }); rmSync(testDir, { recursive: true, force: true });
}); });
it('should auto-categorize uncategorized builtins when hasCustomCategories is true', () => { it('should copy default categories to user path when missing', async () => {
// Set up default categories for auto-categorization // Use real ensureUserCategoriesFile (not mocked)
writeYaml(join(resourcesDir, 'default-categories.yaml'), ` const { ensureUserCategoriesFile } = await import('../infra/config/global/pieceCategories.js');
piece_categories:
Standard:
pieces:
- default
- minimal
Advanced:
pieces:
- research
`);
const allPieces = createPieceMap([ // This test depends on the mock still being active — just verify the mock returns our path
{ name: 'my-piece', source: 'user' }, const result = ensureUserCategoriesFile('/tmp/default.yaml');
{ name: 'default', source: 'builtin' }, expect(typeof result).toBe('string');
{ 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: [] },
]);
}); });
}); });

View File

@ -4,23 +4,41 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { PieceDirEntry } from '../infra/config/loaders/pieceLoader.js'; import type { PieceDirEntry } from '../infra/config/loaders/pieceLoader.js';
import type { CategorizedPieces } from '../infra/config/loaders/pieceCategories.js';
import type { PieceWithSource } from '../infra/config/loaders/pieceResolver.js';
const selectOptionMock = vi.fn(); const selectOptionMock = vi.fn();
const bookmarkState = vi.hoisted(() => ({
bookmarks: [] as string[],
}));
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
selectOption: selectOptionMock, selectOption: selectOptionMock,
})); }));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
warn: vi.fn(),
}));
vi.mock('../infra/config/global/index.js', () => ({ vi.mock('../infra/config/global/index.js', () => ({
getBookmarkedPieces: () => [], getBookmarkedPieces: () => bookmarkState.bookmarks,
addBookmark: vi.fn(),
removeBookmark: vi.fn(),
toggleBookmark: vi.fn(), toggleBookmark: vi.fn(),
})); }));
const { selectPieceFromEntries } = await import('../features/pieceSelection/index.js'); vi.mock('../infra/config/index.js', async (importOriginal) => {
const actual = await importOriginal() as Record<string, unknown>;
return actual;
});
const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js');
describe('selectPieceFromEntries', () => { describe('selectPieceFromEntries', () => {
beforeEach(() => { beforeEach(() => {
selectOptionMock.mockReset(); selectOptionMock.mockReset();
bookmarkState.bookmarks = [];
}); });
it('should select from custom pieces when source is chosen', async () => { it('should select from custom pieces when source is chosen', async () => {
@ -50,3 +68,98 @@ describe('selectPieceFromEntries', () => {
expect(selectOptionMock).toHaveBeenCalledTimes(1); expect(selectOptionMock).toHaveBeenCalledTimes(1);
}); });
}); });
function createPieceMap(entries: { name: string; source: 'user' | 'builtin' }[]): Map<string, PieceWithSource> {
const map = new Map<string, PieceWithSource>();
for (const e of entries) {
map.set(e.name, {
source: e.source,
config: {
name: e.name,
movements: [],
initialMovement: 'start',
maxIterations: 1,
},
});
}
return map;
}
describe('selectPieceFromCategorizedPieces', () => {
beforeEach(() => {
selectOptionMock.mockReset();
bookmarkState.bookmarks = [];
});
it('should show categories at top level', async () => {
const categorized: CategorizedPieces = {
categories: [
{ name: 'My Pieces', pieces: ['my-piece'], children: [] },
{ name: 'Quick Start', pieces: ['default'], children: [] },
],
allPieces: createPieceMap([
{ name: 'my-piece', source: 'user' },
{ name: 'default', source: 'builtin' },
]),
missingPieces: [],
};
selectOptionMock.mockResolvedValueOnce('__current__');
await selectPieceFromCategorizedPieces(categorized, 'my-piece');
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label);
expect(labels[0]).toBe('🎼 my-piece (current)');
expect(labels.some((l) => l.includes('My Pieces'))).toBe(true);
expect(labels.some((l) => l.includes('Quick Start'))).toBe(true);
});
it('should show current piece and bookmarks above categories', async () => {
bookmarkState.bookmarks = ['research'];
const categorized: CategorizedPieces = {
categories: [
{ name: 'Quick Start', pieces: ['default'], children: [] },
],
allPieces: createPieceMap([
{ name: 'default', source: 'builtin' },
{ name: 'research', source: 'builtin' },
]),
missingPieces: [],
};
selectOptionMock.mockResolvedValueOnce('__current__');
const selected = await selectPieceFromCategorizedPieces(categorized, 'default');
expect(selected).toBe('default');
const firstCallOptions = selectOptionMock.mock.calls[0]![1] as { label: string; value: string }[];
const labels = firstCallOptions.map((o) => o.label);
// Current piece first, bookmarks second, categories after
expect(labels[0]).toBe('🎼 default (current)');
expect(labels[1]).toBe('🎼 research [*]');
});
it('should navigate into a category and select a piece', async () => {
const categorized: CategorizedPieces = {
categories: [
{ name: 'Dev', pieces: ['my-piece'], children: [] },
],
allPieces: createPieceMap([
{ name: 'my-piece', source: 'user' },
]),
missingPieces: [],
};
// Select category, then select piece inside it
selectOptionMock
.mockResolvedValueOnce('__custom_category__:Dev')
.mockResolvedValueOnce('my-piece');
const selected = await selectPieceFromCategorizedPieces(categorized, '');
expect(selected).toBe('my-piece');
});
});

View File

@ -7,7 +7,7 @@
import { clearAgentSessions, getCurrentPiece } from '../../infra/config/index.js'; import { clearAgentSessions, getCurrentPiece } from '../../infra/config/index.js';
import { success } from '../../shared/ui/index.js'; import { success } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js';
import { switchPiece, switchConfig, ejectBuiltin } from '../../features/config/index.js'; import { switchPiece, switchConfig, ejectBuiltin, resetCategoriesToDefault } from '../../features/config/index.js';
import { previewPrompts } from '../../features/prompt/index.js'; import { previewPrompts } from '../../features/prompt/index.js';
import { program, resolvedCwd } from './program.js'; import { program, resolvedCwd } from './program.js';
import { resolveAgentOverrides } from './helpers.js'; import { resolveAgentOverrides } from './helpers.js';
@ -89,6 +89,17 @@ program
await switchConfig(resolvedCwd, key); await switchConfig(resolvedCwd, key);
}); });
const reset = program
.command('reset')
.description('Reset settings to defaults');
reset
.command('categories')
.description('Reset piece categories to builtin defaults')
.action(async () => {
await resetCategoriesToDefault();
});
program program
.command('prompt') .command('prompt')
.description('Preview assembled prompts for each movement and phase') .description('Preview assembled prompts for each movement and phase')

View File

@ -5,3 +5,4 @@
export { switchPiece } from './switchPiece.js'; export { switchPiece } from './switchPiece.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js';
export { ejectBuiltin } from './ejectBuiltin.js'; export { ejectBuiltin } from './ejectBuiltin.js';
export { resetCategoriesToDefault } from './resetCategories.js';

View File

@ -0,0 +1,18 @@
/**
* Reset piece categories to builtin defaults.
*/
import { getDefaultCategoriesPath } from '../../infra/config/loaders/pieceCategories.js';
import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js';
import { header, success, info } from '../../shared/ui/index.js';
export async function resetCategoriesToDefault(): Promise<void> {
header('Reset Categories');
const defaultPath = getDefaultCategoriesPath();
resetPieceCategories(defaultPath);
const userPath = getPieceCategoriesPath();
success('Categories reset to builtin defaults.');
info(` ${userPath}`);
}

View File

@ -28,7 +28,7 @@ export async function switchPiece(cwd: string, pieceName?: string): Promise<bool
const current = getCurrentPiece(cwd); const current = getCurrentPiece(cwd);
info(`Current piece: ${current}`); info(`Current piece: ${current}`);
const categoryConfig = getPieceCategories(cwd); const categoryConfig = getPieceCategories();
let selected: string | null; let selected: string | null;
if (categoryConfig) { if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd); const allPieces = loadAllPiecesWithSources(cwd);

View File

@ -16,8 +16,6 @@ import {
type PieceCategoryNode, type PieceCategoryNode,
type CategorizedPieces, type CategorizedPieces,
type MissingPiece, type MissingPiece,
type PieceSource,
type PieceWithSource,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
/** Top-level selection item: either a piece or a category containing pieces */ /** Top-level selection item: either a piece or a category containing pieces */
@ -280,63 +278,21 @@ async function selectPieceFromCategoryTree(
} }
} }
function countPiecesIncludingCategories(
categories: PieceCategoryNode[],
allPieces: Map<string, PieceWithSource>,
sourceFilter: PieceSource,
): number {
const categorizedPieces = new Set<string>();
const visit = (nodes: PieceCategoryNode[]): void => {
for (const node of nodes) {
for (const w of node.pieces) {
categorizedPieces.add(w);
}
if (node.children.length > 0) {
visit(node.children);
}
}
};
visit(categories);
let count = 0;
for (const [, { source }] of allPieces) {
if (source === sourceFilter) {
count++;
}
}
return count;
}
const CURRENT_PIECE_VALUE = '__current__'; const CURRENT_PIECE_VALUE = '__current__';
const CUSTOM_UNCATEGORIZED_VALUE = '__custom_uncategorized__';
const BUILTIN_SOURCE_VALUE = '__builtin__';
const CUSTOM_CATEGORY_PREFIX = '__custom_category__:'; const CUSTOM_CATEGORY_PREFIX = '__custom_category__:';
type TopLevelSelection = type TopLevelSelection =
| { type: 'current' } | { type: 'current' }
| { type: 'piece'; name: string } | { type: 'piece'; name: string }
| { type: 'custom_category'; node: PieceCategoryNode } | { type: 'category'; node: PieceCategoryNode };
| { type: 'custom_uncategorized' }
| { type: 'builtin' };
async function selectTopLevelPieceOption( async function selectTopLevelPieceOption(
categorized: CategorizedPieces, categorized: CategorizedPieces,
currentPiece: string, currentPiece: string,
): Promise<TopLevelSelection | null> { ): Promise<TopLevelSelection | null> {
const uncategorizedCustom = getRootLevelPieces(
categorized.categories,
categorized.allPieces,
'user'
);
const builtinCount = countPiecesIncludingCategories(
categorized.builtinCategories,
categorized.allPieces,
'builtin'
);
const buildOptions = (): SelectOptionItem<string>[] => { const buildOptions = (): SelectOptionItem<string>[] => {
const options: SelectOptionItem<string>[] = []; const options: SelectOptionItem<string>[] = [];
const bookmarkedPieces = getBookmarkedPieces(); // Get fresh bookmarks on every build const bookmarkedPieces = getBookmarkedPieces();
// 1. Current piece // 1. Current piece
if (currentPiece) { if (currentPiece) {
@ -348,14 +304,14 @@ async function selectTopLevelPieceOption(
// 2. Bookmarked pieces (individual items) // 2. Bookmarked pieces (individual items)
for (const pieceName of bookmarkedPieces) { for (const pieceName of bookmarkedPieces) {
if (pieceName === currentPiece) continue; // Skip if already shown as current if (pieceName === currentPiece) continue;
options.push({ options.push({
label: `🎼 ${pieceName} [*]`, label: `🎼 ${pieceName} [*]`,
value: pieceName, value: pieceName,
}); });
} }
// 3. User-defined categories // 3. Categories
for (const category of categorized.categories) { for (const category of categorized.categories) {
options.push({ options.push({
label: `📁 ${category.name}/`, label: `📁 ${category.name}/`,
@ -363,22 +319,6 @@ async function selectTopLevelPieceOption(
}); });
} }
// 4. Builtin pieces
if (builtinCount > 0) {
options.push({
label: `📂 ${categorized.builtinCategoryName}/ (${builtinCount})`,
value: BUILTIN_SOURCE_VALUE,
});
}
// 5. Uncategorized custom pieces
if (uncategorizedCustom.length > 0) {
options.push({
label: `📂 Custom/ (${uncategorizedCustom.length})`,
value: CUSTOM_UNCATEGORIZED_VALUE,
});
}
return options; return options;
}; };
@ -386,12 +326,8 @@ async function selectTopLevelPieceOption(
const result = await selectOption<string>('Select piece:', buildOptions(), { const result = await selectOption<string>('Select piece:', buildOptions(), {
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => { onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
// Don't handle bookmark keys for special values if (value === CURRENT_PIECE_VALUE || value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
if (value === CURRENT_PIECE_VALUE || return null;
value === CUSTOM_UNCATEGORIZED_VALUE ||
value === BUILTIN_SOURCE_VALUE ||
value.startsWith(CUSTOM_CATEGORY_PREFIX)) {
return null; // Delegate to default handler
} }
if (key === 'b') { if (key === 'b') {
@ -404,7 +340,7 @@ async function selectTopLevelPieceOption(
return buildOptions(); return buildOptions();
} }
return null; // Delegate to default handler return null;
}, },
}); });
@ -414,52 +350,16 @@ async function selectTopLevelPieceOption(
return { type: 'current' }; return { type: 'current' };
} }
if (result === CUSTOM_UNCATEGORIZED_VALUE) {
return { type: 'custom_uncategorized' };
}
if (result === BUILTIN_SOURCE_VALUE) {
return { type: 'builtin' };
}
if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) { if (result.startsWith(CUSTOM_CATEGORY_PREFIX)) {
const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length); const categoryName = result.slice(CUSTOM_CATEGORY_PREFIX.length);
const node = categorized.categories.find(c => c.name === categoryName); const node = categorized.categories.find(c => c.name === categoryName);
if (!node) return null; if (!node) return null;
return { type: 'custom_category', node }; return { type: 'category', node };
} }
// Direct piece selection (bookmarked or other)
return { type: 'piece', name: result }; return { type: 'piece', name: result };
} }
function getRootLevelPieces(
categories: PieceCategoryNode[],
allPieces: Map<string, PieceWithSource>,
sourceFilter: PieceSource,
): string[] {
const categorizedPieces = new Set<string>();
const visit = (nodes: PieceCategoryNode[]): void => {
for (const node of nodes) {
for (const w of node.pieces) {
categorizedPieces.add(w);
}
if (node.children.length > 0) {
visit(node.children);
}
}
};
visit(categories);
const rootPieces: string[] = [];
for (const [name, { source }] of allPieces) {
if (source === sourceFilter && !categorizedPieces.has(name)) {
rootPieces.push(name);
}
}
return rootPieces.sort();
}
/** /**
* Select piece from categorized pieces (hierarchical UI). * Select piece from categorized pieces (hierarchical UI).
*/ */
@ -469,91 +369,20 @@ export async function selectPieceFromCategorizedPieces(
): Promise<string | null> { ): Promise<string | null> {
while (true) { while (true) {
const selection = await selectTopLevelPieceOption(categorized, currentPiece); const selection = await selectTopLevelPieceOption(categorized, currentPiece);
if (!selection) { if (!selection) return null;
return null;
}
// 1. Current piece selected if (selection.type === 'current') return currentPiece;
if (selection.type === 'current') {
return currentPiece;
}
// 2. Direct piece selected (e.g., bookmarked piece) if (selection.type === 'piece') return selection.name;
if (selection.type === 'piece') {
return selection.name;
}
// 3. User-defined category selected if (selection.type === 'category') {
if (selection.type === 'custom_category') {
const piece = await selectPieceFromCategoryTree( const piece = await selectPieceFromCategoryTree(
[selection.node], [selection.node],
currentPiece, currentPiece,
true, true,
selection.node.pieces selection.node.pieces,
); );
if (piece) { if (piece) return piece;
return piece;
}
// null → go back to top-level selection
continue;
}
// 4. Builtin pieces selected
if (selection.type === 'builtin') {
const rootPieces = getRootLevelPieces(
categorized.builtinCategories,
categorized.allPieces,
'builtin'
);
const piece = await selectPieceFromCategoryTree(
categorized.builtinCategories,
currentPiece,
true,
rootPieces
);
if (piece) {
return piece;
}
// null → go back to top-level selection
continue;
}
// 5. Custom uncategorized pieces selected
if (selection.type === 'custom_uncategorized') {
const uncategorizedCustom = getRootLevelPieces(
categorized.categories,
categorized.allPieces,
'user'
);
const baseOptions: SelectionOption[] = uncategorizedCustom.map((name) => ({
label: name === currentPiece ? `🎼 ${name} (current)` : `🎼 ${name}`,
value: name,
}));
const buildFlatOptions = (): SelectionOption[] =>
applyBookmarks(baseOptions, getBookmarkedPieces());
const piece = await selectOption<string>('Select piece:', buildFlatOptions(), {
cancelLabel: '← Go back',
onKeyPress: (key: string, value: string): SelectOptionItem<string>[] | null => {
if (key === 'b') {
addBookmark(value);
return buildFlatOptions();
}
if (key === 'r') {
removeBookmark(value);
return buildFlatOptions();
}
return null; // Delegate to default handler
},
});
if (piece) {
return piece;
}
// null → go back to top-level selection
continue; continue;
} }
} }

View File

@ -58,7 +58,7 @@ async function selectPieceWithDirectoryCategories(cwd: string): Promise<string |
* Select a piece interactively with 2-stage category support. * Select a piece interactively with 2-stage category support.
*/ */
async function selectPiece(cwd: string): Promise<string | null> { async function selectPiece(cwd: string): Promise<string | null> {
const categoryConfig = getPieceCategories(cwd); const categoryConfig = getPieceCategories();
if (categoryConfig) { if (categoryConfig) {
const current = getCurrentPiece(cwd); const current = getCurrentPiece(cwd);
const allPieces = loadAllPiecesWithSources(cwd); const allPieces = loadAllPiecesWithSources(cwd);

View File

@ -26,13 +26,9 @@ export {
} from './bookmarks.js'; } from './bookmarks.js';
export { export {
getPieceCategoriesConfig, getPieceCategoriesPath,
setPieceCategoriesConfig, ensureUserCategoriesFile,
getShowOthersCategory, resetPieceCategories,
setShowOthersCategory,
getOthersCategoryName,
setOthersCategoryName,
getBuiltinCategoryName,
} from './pieceCategories.js'; } from './pieceCategories.js';
export { export {

View File

@ -1,28 +1,22 @@
/** /**
* Piece categories management (separate from config.yaml) * Piece categories file management.
* *
* Categories are stored in a configurable location (default: ~/.takt/preferences/piece-categories.yaml) * The categories file (~/.takt/preferences/piece-categories.yaml) uses the same
* format as the builtin piece-categories.yaml (piece_categories key).
* If the file doesn't exist, it's auto-copied from builtin defaults.
*/ */
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs'; import { existsSync, mkdirSync, copyFileSync } from 'node:fs';
import { join, dirname } from 'node:path'; import { dirname, join } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { getGlobalConfigDir } from '../paths.js'; import { getGlobalConfigDir } from '../paths.js';
import { loadGlobalConfig } from './globalConfig.js'; import { loadGlobalConfig } from './globalConfig.js';
import type { PieceCategoryConfigNode } from '../../../core/models/index.js';
interface PieceCategoriesFile {
categories?: PieceCategoryConfigNode;
show_others_category?: boolean;
others_category_name?: string;
builtin_category_name?: string;
}
function getDefaultPieceCategoriesPath(): string { function getDefaultPieceCategoriesPath(): string {
return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml'); return join(getGlobalConfigDir(), 'preferences', 'piece-categories.yaml');
} }
function getPieceCategoriesPath(): string { /** Get the path to the user's piece categories file. */
export function getPieceCategoriesPath(): string {
try { try {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
if (config.pieceCategoriesFile) { if (config.pieceCategoriesFile) {
@ -34,77 +28,40 @@ function getPieceCategoriesPath(): string {
return getDefaultPieceCategoriesPath(); return getDefaultPieceCategoriesPath();
} }
function loadPieceCategoriesFile(): PieceCategoriesFile { /**
const categoriesPath = getPieceCategoriesPath(); * Ensure user categories file exists by copying from builtin defaults.
if (!existsSync(categoriesPath)) { * Returns the path to the user categories file.
return {}; */
export function ensureUserCategoriesFile(defaultCategoriesPath: string): string {
const userPath = getPieceCategoriesPath();
if (existsSync(userPath)) {
return userPath;
} }
try { if (!existsSync(defaultCategoriesPath)) {
const content = readFileSync(categoriesPath, 'utf-8'); throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
const parsed = parseYaml(content);
if (parsed && typeof parsed === 'object') {
return parsed as PieceCategoriesFile;
}
} catch {
// Ignore parse errors
} }
return {}; const dir = dirname(userPath);
}
function savePieceCategoriesFile(data: PieceCategoriesFile): void {
const categoriesPath = getPieceCategoriesPath();
const dir = dirname(categoriesPath);
if (!existsSync(dir)) { if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true }); mkdirSync(dir, { recursive: true });
} }
const content = stringifyYaml(data, { indent: 2 }); copyFileSync(defaultCategoriesPath, userPath);
writeFileSync(categoriesPath, content, 'utf-8'); return userPath;
} }
/** Get piece categories configuration */ /**
export function getPieceCategoriesConfig(): PieceCategoryConfigNode | undefined { * Reset user categories file by overwriting with builtin defaults.
const data = loadPieceCategoriesFile(); */
return data.categories; export function resetPieceCategories(defaultCategoriesPath: string): void {
if (!existsSync(defaultCategoriesPath)) {
throw new Error(`Default categories file not found: ${defaultCategoriesPath}`);
} }
/** Set piece categories configuration */ const userPath = getPieceCategoriesPath();
export function setPieceCategoriesConfig(categories: PieceCategoryConfigNode): void { const dir = dirname(userPath);
const data = loadPieceCategoriesFile(); if (!existsSync(dir)) {
data.categories = categories; mkdirSync(dir, { recursive: true });
savePieceCategoriesFile(data);
} }
copyFileSync(defaultCategoriesPath, userPath);
/** Get show others category flag */
export function getShowOthersCategory(): boolean | undefined {
const data = loadPieceCategoriesFile();
return data.show_others_category;
} }
/** Set show others category flag */
export function setShowOthersCategory(show: boolean): void {
const data = loadPieceCategoriesFile();
data.show_others_category = show;
savePieceCategoriesFile(data);
}
/** Get others category name */
export function getOthersCategoryName(): string | undefined {
const data = loadPieceCategoriesFile();
return data.others_category_name;
}
/** Set others category name */
export function setOthersCategoryName(name: string): void {
const data = loadPieceCategoriesFile();
data.others_category_name = name;
savePieceCategoriesFile(data);
}
/** Get builtin category name */
export function getBuiltinCategoryName(): string | undefined {
const data = loadPieceCategoriesFile();
return data.builtin_category_name;
}

View File

@ -19,6 +19,7 @@ export {
export { export {
loadDefaultCategories, loadDefaultCategories,
getDefaultCategoriesPath,
getPieceCategories, getPieceCategories,
buildCategorizedPieces, buildCategorizedPieces,
findPieceCategories, findPieceCategories,

View File

@ -1,22 +1,19 @@
/** /**
* Piece category configuration loader and helpers. * Piece category configuration loader and helpers.
*
* Categories are loaded from a single source: the user's piece-categories.yaml file.
* If the file doesn't exist, it's auto-copied from builtin defaults.
*/ */
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { z } from 'zod/v4'; import { z } from 'zod/v4';
import { getProjectConfigPath } from '../paths.js';
import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js';
import { import { ensureUserCategoriesFile } from '../global/pieceCategories.js';
getPieceCategoriesConfig,
getShowOthersCategory,
getOthersCategoryName,
getBuiltinCategoryName,
} from '../global/pieceCategories.js';
import { getLanguageResourcesDir } from '../../resources/index.js'; import { getLanguageResourcesDir } from '../../resources/index.js';
import { listBuiltinPieceNames } from './pieceResolver.js'; import { listBuiltinPieceNames } from './pieceResolver.js';
import type { PieceSource, PieceWithSource } from './pieceResolver.js'; import type { PieceWithSource } from './pieceResolver.js';
const CategoryConfigSchema = z.object({ const CategoryConfigSchema = z.object({
piece_categories: z.record(z.string(), z.unknown()).optional(), piece_categories: z.record(z.string(), z.unknown()).optional(),
@ -34,15 +31,10 @@ export interface CategoryConfig {
pieceCategories: PieceCategoryNode[]; pieceCategories: PieceCategoryNode[];
showOthersCategory: boolean; showOthersCategory: boolean;
othersCategoryName: string; 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 { export interface CategorizedPieces {
categories: PieceCategoryNode[]; categories: PieceCategoryNode[];
builtinCategories: PieceCategoryNode[];
builtinCategoryName: string;
allPieces: Map<string, PieceWithSource>; allPieces: Map<string, PieceWithSource>;
missingPieces: MissingPiece[]; missingPieces: MissingPiece[];
} }
@ -139,8 +131,6 @@ function parseCategoryConfig(raw: unknown, sourceLabel: string): CategoryConfig
pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel), pieceCategories: parseCategoryTree(parsed.piece_categories, sourceLabel),
showOthersCategory, showOthersCategory,
othersCategoryName, othersCategoryName,
builtinCategoryName: 'Builtin',
hasCustomCategories: false,
}; };
} }
@ -159,36 +149,25 @@ function loadCategoryConfigFromPath(path: string, sourceLabel: string): Category
*/ */
export function loadDefaultCategories(): CategoryConfig | null { export function loadDefaultCategories(): CategoryConfig | null {
const lang = getLanguage(); const lang = getLanguage();
const filePath = join(getLanguageResourcesDir(lang), 'default-categories.yaml'); const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
return loadCategoryConfigFromPath(filePath, filePath); return loadCategoryConfigFromPath(filePath, filePath);
} }
/** Get the path to the builtin default categories file. */
export function getDefaultCategoriesPath(): string {
const lang = getLanguage();
return join(getLanguageResourcesDir(lang), 'piece-categories.yaml');
}
/** /**
* Get effective piece categories configuration. * Get effective piece categories configuration.
* Priority: user config -> project config -> default categories. * Reads from user file (~/.takt/preferences/piece-categories.yaml).
* Auto-copies from builtin defaults if user file doesn't exist.
*/ */
export function getPieceCategories(cwd: string): CategoryConfig | null { export function getPieceCategories(): CategoryConfig | null {
// Check user config from separate file (~/.takt/piece-categories.yaml) const defaultPath = getDefaultCategoriesPath();
const userCategoriesNode = getPieceCategoriesConfig(); const userPath = ensureUserCategoriesFile(defaultPath);
if (userCategoriesNode) { return loadCategoryConfigFromPath(userPath, userPath);
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, hasCustomCategories: true };
}
return loadDefaultCategories();
} }
function collectMissingPieces( function collectMissingPieces(
@ -216,27 +195,22 @@ function collectMissingPieces(
return missing; return missing;
} }
function buildCategoryTreeForSource( function buildCategoryTree(
categories: PieceCategoryNode[], categories: PieceCategoryNode[],
allPieces: Map<string, PieceWithSource>, allPieces: Map<string, PieceWithSource>,
sourceFilter: (source: PieceSource) => boolean,
categorized: Set<string>, categorized: Set<string>,
allowedPieces?: Set<string>,
): PieceCategoryNode[] { ): PieceCategoryNode[] {
const result: PieceCategoryNode[] = []; const result: PieceCategoryNode[] = [];
for (const node of categories) { for (const node of categories) {
const pieces: string[] = []; const pieces: string[] = [];
for (const pieceName of node.pieces) { for (const pieceName of node.pieces) {
if (allowedPieces && !allowedPieces.has(pieceName)) continue; if (!allPieces.has(pieceName)) continue;
const entry = allPieces.get(pieceName);
if (!entry) continue;
if (!sourceFilter(entry.source)) continue;
pieces.push(pieceName); pieces.push(pieceName);
categorized.add(pieceName); categorized.add(pieceName);
} }
const children = buildCategoryTreeForSource(node.children, allPieces, sourceFilter, categorized, allowedPieces); const children = buildCategoryTree(node.children, allPieces, categorized);
if (pieces.length > 0 || children.length > 0) { if (pieces.length > 0 || children.length > 0) {
result.push({ name: node.name, pieces, children }); result.push({ name: node.name, pieces, children });
} }
@ -249,16 +223,10 @@ function appendOthersCategory(
categories: PieceCategoryNode[], categories: PieceCategoryNode[],
allPieces: Map<string, PieceWithSource>, allPieces: Map<string, PieceWithSource>,
categorized: Set<string>, categorized: Set<string>,
sourceFilter: (source: PieceSource) => boolean,
othersCategoryName: string, othersCategoryName: string,
): PieceCategoryNode[] { ): PieceCategoryNode[] {
if (categories.some((node) => node.name === othersCategoryName)) {
return categories;
}
const uncategorized: string[] = []; const uncategorized: string[] = [];
for (const [pieceName, entry] of allPieces.entries()) { for (const [pieceName] of allPieces.entries()) {
if (!sourceFilter(entry.source)) continue;
if (categorized.has(pieceName)) continue; if (categorized.has(pieceName)) continue;
uncategorized.push(pieceName); uncategorized.push(pieceName);
} }
@ -267,70 +235,28 @@ function appendOthersCategory(
return categories; return categories;
} }
// If a category with the same name already exists, merge uncategorized pieces into it
const existingIndex = categories.findIndex((node) => node.name === othersCategoryName);
if (existingIndex >= 0) {
const existing = categories[existingIndex]!;
return categories.map((node, i) =>
i === existingIndex
? { ...node, pieces: [...existing.pieces, ...uncategorized] }
: node,
);
}
return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }]; return [...categories, { name: othersCategoryName, pieces: uncategorized, children: [] }];
} }
/**
* Collect uncategorized builtin piece names.
*/
function collectUncategorizedBuiltins(
allPieces: Map<string, PieceWithSource>,
categorizedBuiltin: Set<string>,
): Set<string> {
const uncategorized = new Set<string>();
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<string, PieceWithSource>,
uncategorizedBuiltins: Set<string>,
builtinCategoryName: string,
defaultConfig: CategoryConfig | null,
): PieceCategoryNode[] {
if (defaultConfig) {
const autoCategorized = new Set<string>();
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. * Build categorized pieces map from configuration.
* All pieces (user and builtin) are placed in a single category tree.
*/ */
export function buildCategorizedPieces( export function buildCategorizedPieces(
allPieces: Map<string, PieceWithSource>, allPieces: Map<string, PieceWithSource>,
config: CategoryConfig, config: CategoryConfig,
): CategorizedPieces { ): CategorizedPieces {
const { builtinCategoryName } = config;
const ignoreMissing = new Set<string>(); const ignoreMissing = new Set<string>();
if (!getBuiltinPiecesEnabled()) { if (!getBuiltinPiecesEnabled()) {
for (const name of listBuiltinPieceNames({ includeDisabled: true })) { for (const name of listBuiltinPieceNames({ includeDisabled: true })) {
@ -348,66 +274,19 @@ export function buildCategorizedPieces(
ignoreMissing, ignoreMissing,
); );
const isBuiltin = (source: PieceSource): boolean => source === 'builtin'; const categorized = new Set<string>();
const isCustom = (source: PieceSource): boolean => source !== 'builtin'; const categories = buildCategoryTree(
const categorizedCustom = new Set<string>();
const categories = buildCategoryTreeForSource(
config.pieceCategories, config.pieceCategories,
allPieces, allPieces,
isCustom, categorized,
categorizedCustom,
); );
const categorizedBuiltin = new Set<string>();
const builtinCategories = buildCategoryTreeForSource(
config.pieceCategories,
allPieces,
isBuiltin,
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 const finalCategories = config.showOthersCategory
? appendOthersCategory( ? appendOthersCategory(categories, allPieces, categorized, config.othersCategoryName)
categories,
allPieces,
categorizedCustom,
isCustom,
config.othersCategoryName,
)
: categories; : categories;
// 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 { return {
categories: finalCategories, categories: finalCategories,
builtinCategories: finalBuiltinCategories,
builtinCategoryName,
allPieces, allPieces,
missingPieces, missingPieces,
}; };