github-issue-255-ui (#266)

* update builtin

* fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長

* takt: github-issue-255-ui

* 無駄な条件分岐を削除
This commit is contained in:
nrs 2026-02-13 21:59:00 +09:00 committed by GitHub
parent 3ff6f99152
commit 02272e595c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 190 additions and 215 deletions

View File

@ -33,7 +33,19 @@ vi.mock('../infra/config/index.js', async (importOriginal) => {
return actual; return actual;
}); });
const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js'); const configMock = vi.hoisted(() => ({
listPieces: vi.fn(),
listPieceEntries: vi.fn(),
loadAllPiecesWithSources: vi.fn(),
getPieceCategories: vi.fn(),
buildCategorizedPieces: vi.fn(),
getCurrentPiece: vi.fn(),
findPieceCategories: vi.fn(() => []),
}));
vi.mock('../infra/config/index.js', () => configMock);
const { selectPieceFromEntries, selectPieceFromCategorizedPieces, selectPiece } = await import('../features/pieceSelection/index.js');
describe('selectPieceFromEntries', () => { describe('selectPieceFromEntries', () => {
beforeEach(() => { beforeEach(() => {
@ -231,3 +243,93 @@ describe('selectPieceFromCategorizedPieces', () => {
expect(labels.some((l) => l.includes('Dev'))).toBe(false); expect(labels.some((l) => l.includes('Dev'))).toBe(false);
}); });
}); });
describe('selectPiece', () => {
const entries: PieceDirEntry[] = [
{ name: 'custom-flow', path: '/tmp/custom.yaml', source: 'user' },
{ name: 'builtin-flow', path: '/tmp/builtin.yaml', source: 'builtin' },
];
beforeEach(() => {
selectOptionMock.mockReset();
bookmarkState.bookmarks = [];
configMock.listPieces.mockReset();
configMock.listPieceEntries.mockReset();
configMock.loadAllPiecesWithSources.mockReset();
configMock.getPieceCategories.mockReset();
configMock.buildCategorizedPieces.mockReset();
configMock.getCurrentPiece.mockReset();
});
it('should return default piece when no pieces found and fallbackToDefault is true', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]);
configMock.getCurrentPiece.mockReturnValue('default');
const result = await selectPiece('/cwd');
expect(result).toBe('default');
});
it('should return null when no pieces found and fallbackToDefault is false', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]);
configMock.getCurrentPiece.mockReturnValue('default');
const result = await selectPiece('/cwd', { fallbackToDefault: false });
expect(result).toBeNull();
});
it('should prompt selection even when only one piece exists', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['only-piece']);
configMock.listPieceEntries.mockReturnValue([
{ name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' },
]);
configMock.getCurrentPiece.mockReturnValue('only-piece');
selectOptionMock.mockResolvedValueOnce('only-piece');
const result = await selectPiece('/cwd');
expect(result).toBe('only-piece');
expect(selectOptionMock).toHaveBeenCalled();
});
it('should use category-based selection when category config exists', async () => {
const pieceMap = createPieceMap([{ name: 'my-piece', source: 'user' }]);
const categorized: CategorizedPieces = {
categories: [{ name: 'Dev', pieces: ['my-piece'], children: [] }],
allPieces: pieceMap,
missingPieces: [],
};
configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] });
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
configMock.buildCategorizedPieces.mockReturnValue(categorized);
configMock.getCurrentPiece.mockReturnValue('my-piece');
selectOptionMock.mockResolvedValueOnce('__current__');
const result = await selectPiece('/cwd');
expect(result).toBe('my-piece');
expect(configMock.buildCategorizedPieces).toHaveBeenCalled();
});
it('should use directory-based selection when no category config', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);
configMock.listPieceEntries.mockReturnValue(entries);
configMock.getCurrentPiece.mockReturnValue('piece-a');
selectOptionMock
.mockResolvedValueOnce('custom')
.mockResolvedValueOnce('custom-flow');
const result = await selectPiece('/cwd');
expect(result).toBe('custom-flow');
expect(configMock.listPieceEntries).toHaveBeenCalled();
});
});

View File

@ -13,9 +13,6 @@ vi.mock('../infra/config/index.js', () => ({
listPieces: vi.fn(() => ['default']), listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []), listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false), isPiecePath: vi.fn(() => false),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadGlobalConfig: vi.fn(() => ({})), loadGlobalConfig: vi.fn(() => ({})),
})); }));
@ -60,29 +57,25 @@ vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(), warnMissingPieces: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(), selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(), selectPieceFromEntries: vi.fn(),
selectPiece: vi.fn(),
})); }));
import { confirm } from '../shared/prompt/index.js'; import { confirm } from '../shared/prompt/index.js';
import { import {
getCurrentPiece, getCurrentPiece,
loadAllPiecesWithSources, listPieces,
getPieceCategories,
buildCategorizedPieces,
} from '../infra/config/index.js'; } from '../infra/config/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js';
import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js'; import { selectPiece } from '../features/pieceSelection/index.js';
import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js'; import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockGetCurrentPiece = vi.mocked(getCurrentPiece); const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); const mockListPieces = vi.mocked(listPieces);
const mockGetPieceCategories = vi.mocked(getPieceCategories);
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces);
const mockCreateSharedClone = vi.mocked(createSharedClone); const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces); const mockSelectPiece = vi.mocked(selectPiece);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@ -121,44 +114,12 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
expect(autoPrCall![1]).toBe(true); expect(autoPrCall![1]).toBe(true);
}); });
it('should warn only user-origin missing pieces during interactive selection', async () => { it('should call selectPiece when no override is provided', async () => {
// Given: category selection is enabled and both builtin/user missing pieces exist mockSelectPiece.mockResolvedValue('selected-piece');
mockGetCurrentPiece.mockReturnValue('default');
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
['default', {
source: 'builtin',
config: {
name: 'default',
movements: [],
initialMovement: 'start',
maxMovements: 1,
},
}],
]));
mockGetPieceCategories.mockReturnValue({
pieceCategories: [],
builtinPieceCategories: [],
userPieceCategories: [],
showOthersCategory: true,
othersCategoryName: 'Others',
});
mockBuildCategorizedPieces.mockReturnValue({
categories: [],
allPieces: new Map(),
missingPieces: [
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
],
});
mockSelectPieceFromCategorizedPieces.mockResolvedValue('default');
// When
const selected = await determinePiece('/project'); const selected = await determinePiece('/project');
// Then expect(selected).toBe('selected-piece');
expect(selected).toBe('default'); expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockWarnMissingPieces).toHaveBeenCalledWith([
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
]);
}); });
}); });

View File

@ -5,19 +5,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
listPieceEntries: vi.fn(() => []),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadPiece: vi.fn(() => null), loadPiece: vi.fn(() => null),
getCurrentPiece: vi.fn(() => 'default'), getCurrentPiece: vi.fn(() => 'default'),
setCurrentPiece: vi.fn(), setCurrentPiece: vi.fn(),
})); }));
vi.mock('../features/pieceSelection/index.js', () => ({ vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(), selectPiece: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -26,65 +20,41 @@ vi.mock('../shared/ui/index.js', () => ({
error: vi.fn(), error: vi.fn(),
})); }));
import { import { getCurrentPiece, loadPiece, setCurrentPiece } from '../infra/config/index.js';
loadAllPiecesWithSources, import { selectPiece } from '../features/pieceSelection/index.js';
getPieceCategories,
buildCategorizedPieces,
} from '../infra/config/index.js';
import {
warnMissingPieces,
selectPieceFromCategorizedPieces,
} from '../features/pieceSelection/index.js';
import { switchPiece } from '../features/config/switchPiece.js'; import { switchPiece } from '../features/config/switchPiece.js';
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
const mockGetPieceCategories = vi.mocked(getPieceCategories); const mockLoadPiece = vi.mocked(loadPiece);
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); const mockSetCurrentPiece = vi.mocked(setCurrentPiece);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces); const mockSelectPiece = vi.mocked(selectPiece);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
describe('switchPiece', () => { describe('switchPiece', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should warn only user-origin missing pieces during interactive switch', async () => { it('should call selectPiece with fallbackToDefault: false', async () => {
// Given mockSelectPiece.mockResolvedValue(null);
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
['default', {
source: 'builtin',
config: {
name: 'default',
movements: [],
initialMovement: 'start',
maxMovements: 1,
},
}],
]));
mockGetPieceCategories.mockReturnValue({
pieceCategories: [],
builtinPieceCategories: [],
userPieceCategories: [],
showOthersCategory: true,
othersCategoryName: 'Others',
});
mockBuildCategorizedPieces.mockReturnValue({
categories: [],
allPieces: new Map(),
missingPieces: [
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
],
});
mockSelectPieceFromCategorizedPieces.mockResolvedValue(null);
// When
const switched = await switchPiece('/project'); const switched = await switchPiece('/project');
// Then
expect(switched).toBe(false); expect(switched).toBe(false);
expect(mockWarnMissingPieces).toHaveBeenCalledWith([ expect(mockSelectPiece).toHaveBeenCalledWith('/project', { fallbackToDefault: false });
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, });
]);
it('should switch to selected piece', async () => {
mockSelectPiece.mockResolvedValue('new-piece');
mockLoadPiece.mockReturnValue({
name: 'new-piece',
movements: [],
initialMovement: 'start',
maxMovements: 1,
});
const switched = await switchPiece('/project');
expect(switched).toBe(true);
expect(mockSetCurrentPiece).toHaveBeenCalledWith('/project', 'new-piece');
}); });
}); });

View File

@ -3,48 +3,23 @@
*/ */
import { import {
listPieceEntries,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadPiece, loadPiece,
getCurrentPiece, getCurrentPiece,
setCurrentPiece, setCurrentPiece,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { info, success, error } from '../../shared/ui/index.js'; import { info, success, error } from '../../shared/ui/index.js';
import { import { selectPiece } from '../pieceSelection/index.js';
warnMissingPieces,
selectPieceFromCategorizedPieces,
selectPieceFromEntries,
} from '../pieceSelection/index.js';
/** /**
* Switch to a different piece * Switch to a different piece
* @returns true if switch was successful * @returns true if switch was successful
*/ */
export async function switchPiece(cwd: string, pieceName?: string): Promise<boolean> { export async function switchPiece(cwd: string, pieceName?: string): Promise<boolean> {
// No piece specified - show selection prompt
if (!pieceName) { if (!pieceName) {
const current = getCurrentPiece(cwd); const current = getCurrentPiece(cwd);
info(`Current piece: ${current}`); info(`Current piece: ${current}`);
const categoryConfig = getPieceCategories(); const selected = await selectPiece(cwd, { fallbackToDefault: false });
let selected: string | null;
if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
info('No pieces found.');
selected = null;
} else {
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
selected = await selectPieceFromCategorizedPieces(categorized, current);
}
} else {
const entries = listPieceEntries(cwd);
selected = await selectPieceFromEntries(entries, current);
}
if (!selected) { if (!selected) {
info('Cancelled'); info('Cancelled');
return false; return false;

View File

@ -12,11 +12,18 @@ import {
} from '../../infra/config/global/index.js'; } from '../../infra/config/global/index.js';
import { import {
findPieceCategories, findPieceCategories,
listPieces,
listPieceEntries,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
getCurrentPiece,
type PieceDirEntry, type PieceDirEntry,
type PieceCategoryNode, type PieceCategoryNode,
type CategorizedPieces, type CategorizedPieces,
type MissingPiece, type MissingPiece,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.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 */
export type PieceSelectionItem = export type PieceSelectionItem =
@ -504,3 +511,44 @@ export async function selectPieceFromEntries(
const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries; const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries;
return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece); return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece);
} }
export interface SelectPieceOptions {
fallbackToDefault?: boolean;
}
export async function selectPiece(
cwd: string,
options?: SelectPieceOptions,
): Promise<string | null> {
const fallbackToDefault = options?.fallbackToDefault !== false;
const categoryConfig = getPieceCategories();
const currentPiece = getCurrentPiece(cwd);
if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
if (fallbackToDefault) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
info('No pieces found.');
return null;
}
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
return selectPieceFromCategorizedPieces(categorized, currentPiece);
}
const availablePieces = listPieces(cwd);
if (availablePieces.length === 0) {
if (fallbackToDefault) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
info('No pieces found.');
return null;
}
const entries = listPieceEntries(cwd);
return selectPieceFromEntries(entries, currentPiece);
}

View File

@ -7,13 +7,8 @@
*/ */
import { import {
getCurrentPiece,
listPieces, listPieces,
listPieceEntries,
isPiecePath, isPiecePath,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadGlobalConfig, loadGlobalConfig,
} from '../../../infra/config/index.js'; } from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
@ -24,63 +19,12 @@ import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js'; import { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import { import { selectPiece } from '../../pieceSelection/index.js';
warnMissingPieces,
selectPieceFromCategorizedPieces,
selectPieceFromEntries,
} from '../../pieceSelection/index.js';
export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; export type { WorktreeConfirmationResult, SelectAndExecuteOptions };
const log = createLogger('selectAndExecute'); const log = createLogger('selectAndExecute');
/**
* Select a piece interactively with directory categories and bookmarks.
*/
async function selectPieceWithDirectoryCategories(cwd: string): Promise<string | null> {
const availablePieces = listPieces(cwd);
const currentPiece = getCurrentPiece(cwd);
if (availablePieces.length === 0) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
if (availablePieces.length === 1 && availablePieces[0]) {
return availablePieces[0];
}
const entries = listPieceEntries(cwd);
return selectPieceFromEntries(entries, currentPiece);
}
/**
* Select a piece interactively with 2-stage category support.
*/
async function selectPiece(cwd: string): Promise<string | null> {
const categoryConfig = getPieceCategories();
if (categoryConfig) {
const current = getCurrentPiece(cwd);
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
return selectPieceFromCategorizedPieces(categorized, current);
}
return selectPieceWithDirectoryCategories(cwd);
}
/**
* Determine piece to use.
*
* - If override looks like a path (isPiecePath), return it directly (validation is done at load time).
* - If override is a name, validate it exists in available pieces.
* - If no override, prompt user to select interactively.
*/
export async function determinePiece(cwd: string, override?: string): Promise<string | null> { export async function determinePiece(cwd: string, override?: string): Promise<string | null> {
if (override) { if (override) {
if (isPiecePath(override)) { if (isPiecePath(override)) {

View File

@ -24,13 +24,11 @@ import { info, success, error as logError, warn, header, blankLine } from '../..
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executeTask } from '../execute/taskExecution.js'; import { executeTask } from '../execute/taskExecution.js';
import type { TaskExecutionOptions } from '../execute/types.js'; import type { TaskExecutionOptions } from '../execute/types.js';
import { listPieces, getCurrentPiece } from '../../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js';
import { selectPiece } from '../../pieceSelection/index.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
/** Actions available for a listed branch */
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
/** /**
@ -254,29 +252,6 @@ export function deleteBranch(projectDir: string, item: BranchListItem): boolean
} }
} }
/**
* Get the piece to use for instruction.
*/
async function selectPieceForInstruction(projectDir: string): Promise<string | null> {
const availablePieces = listPieces(projectDir);
const currentPiece = getCurrentPiece(projectDir);
if (availablePieces.length === 0) {
return DEFAULT_PIECE_NAME;
}
if (availablePieces.length === 1 && availablePieces[0]) {
return availablePieces[0];
}
const options = availablePieces.map((name) => ({
label: name === currentPiece ? `${name} (current)` : name,
value: name,
}));
return await selectOption('Select piece:', options);
}
/** /**
* Get branch context: diff stat and commit log from main branch. * Get branch context: diff stat and commit log from main branch.
*/ */
@ -343,7 +318,7 @@ export async function instructBranch(
return false; return false;
} }
const selectedPiece = await selectPieceForInstruction(projectDir); const selectedPiece = await selectPiece(projectDir);
if (!selectedPiece) { if (!selectedPiece) {
info('Cancelled'); info('Cancelled');
return false; return false;