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
This commit is contained in:
nrslib 2026-02-06 01:22:59 +09:00
parent c3b11df7e0
commit 34a6a4bea2
6 changed files with 308 additions and 14 deletions

4
package-lock.json generated
View File

@ -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",

View File

@ -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: [] },
]);
});
});

View File

@ -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,
});
}

View File

@ -32,6 +32,7 @@ export {
setShowOthersCategory,
getOthersCategoryName,
setOthersCategoryName,
getBuiltinCategoryName,
} from './pieceCategories.js';
export {

View File

@ -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;
}

View File

@ -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<string, PieceWithSource>;
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<string, PieceWithSource>,
sourceFilter: (source: PieceSource) => boolean,
categorized: Set<string>,
allowedPieces?: Set<string>,
): 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<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.
*/
@ -265,6 +329,8 @@ export function buildCategorizedPieces(
allPieces: Map<string, PieceWithSource>,
config: CategoryConfig,
): CategorizedPieces {
const { builtinCategoryName } = config;
const ignoreMissing = new Set<string>();
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,7 +387,13 @@ export function buildCategorizedPieces(
)
: categories;
const finalBuiltinCategories = config.showOthersCategory
// 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,
@ -320,10 +402,12 @@ export function buildCategorizedPieces(
config.othersCategoryName,
)
: builtinCategories;
}
return {
categories: finalCategories,
builtinCategories: finalBuiltinCategories,
builtinCategoryName,
allPieces,
missingPieces,
};