diff --git a/src/__tests__/listNonInteractive.test.ts b/src/__tests__/listNonInteractive.test.ts index 12f93a5..6d38f41 100644 --- a/src/__tests__/listNonInteractive.test.ts +++ b/src/__tests__/listNonInteractive.test.ts @@ -37,7 +37,9 @@ describe('listTasks non-interactive text output', () => { // Then const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[pending] my-task')); + expect(calls).toContainEqual(expect.stringContaining('[running] my-task')); + expect(calls).not.toContainEqual(expect.stringContaining('[pending] my-task')); + expect(calls).not.toContainEqual(expect.stringContaining('[pendig] my-task')); expect(calls).toContainEqual(expect.stringContaining('Fix the login bug')); logSpy.mockRestore(); }); @@ -77,7 +79,9 @@ describe('listTasks non-interactive text output', () => { // Then const calls = logSpy.mock.calls.map((c) => c[0] as string); - expect(calls).toContainEqual(expect.stringContaining('[pending] pending-one')); + expect(calls).toContainEqual(expect.stringContaining('[running] pending-one')); + expect(calls).not.toContainEqual(expect.stringContaining('[pending] pending-one')); + expect(calls).not.toContainEqual(expect.stringContaining('[pendig] pending-one')); expect(calls).toContainEqual(expect.stringContaining('[failed] failed-one')); logSpy.mockRestore(); }); diff --git a/src/__tests__/listTasksInteractivePendingLabel.test.ts b/src/__tests__/listTasksInteractivePendingLabel.test.ts new file mode 100644 index 0000000..f6e3196 --- /dev/null +++ b/src/__tests__/listTasksInteractivePendingLabel.test.ts @@ -0,0 +1,109 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { TaskListItem } from '../infra/task/types.js'; + +const { + mockSelectOption, + mockHeader, + mockInfo, + mockBlankLine, + mockConfirm, + mockListPendingTaskItems, + mockListFailedTasks, + mockDeletePendingTask, +} = vi.hoisted(() => ({ + mockSelectOption: vi.fn(), + mockHeader: vi.fn(), + mockInfo: vi.fn(), + mockBlankLine: vi.fn(), + mockConfirm: vi.fn(), + mockListPendingTaskItems: vi.fn(), + mockListFailedTasks: vi.fn(), + mockDeletePendingTask: vi.fn(), +})); + +vi.mock('../infra/task/index.js', () => ({ + listTaktBranches: vi.fn(() => []), + buildListItems: vi.fn(() => []), + detectDefaultBranch: vi.fn(() => 'main'), + TaskRunner: class { + listPendingTaskItems() { + return mockListPendingTaskItems(); + } + listFailedTasks() { + return mockListFailedTasks(); + } + }, +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: mockSelectOption, + confirm: mockConfirm, +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: mockInfo, + header: mockHeader, + blankLine: mockBlankLine, +})); + +vi.mock('../features/tasks/list/taskActions.js', () => ({ + showFullDiff: vi.fn(), + showDiffAndPromptAction: vi.fn(), + tryMergeBranch: vi.fn(), + mergeBranch: vi.fn(), + deleteBranch: vi.fn(), + instructBranch: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ + deletePendingTask: mockDeletePendingTask, + deleteFailedTask: vi.fn(), +})); + +vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ + retryFailedTask: vi.fn(), +})); + +import { listTasks } from '../features/tasks/list/index.js'; + +describe('listTasks interactive pending label regression', () => { + const pendingTask: TaskListItem = { + kind: 'pending', + name: 'my-task', + createdAt: '2026-02-09T00:00:00', + filePath: '/tmp/my-task.md', + content: 'Fix running status label', + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockListPendingTaskItems.mockReturnValue([pendingTask]); + mockListFailedTasks.mockReturnValue([]); + }); + + it('should show [running] in interactive menu for pending tasks', async () => { + mockSelectOption.mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockSelectOption).toHaveBeenCalledTimes(1); + const menuOptions = mockSelectOption.mock.calls[0]![1] as Array<{ label: string; value: string }>; + expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[running] my-task', value: 'pending:0' })); + expect(menuOptions.some((opt) => opt.label.includes('[pending]'))).toBe(false); + expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false); + }); + + it('should show [running] header when pending task is selected', async () => { + mockSelectOption + .mockResolvedValueOnce('pending:0') + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(null); + + await listTasks('/project'); + + expect(mockHeader).toHaveBeenCalledWith('[running] my-task'); + const headerTexts = mockHeader.mock.calls.map(([text]) => String(text)); + expect(headerTexts.some((text) => text.includes('[pending]'))).toBe(false); + expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false); + }); +}); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 8dfa0bf..1575645 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -30,6 +30,7 @@ import { import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; import { retryFailedTask } from './taskRetryActions.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; +import { formatTaskStatusLabel } from './taskStatusLabel.js'; export type { ListNonInteractiveOptions } from './listNonInteractive.js'; @@ -54,7 +55,7 @@ type FailedTaskAction = 'retry' | 'delete'; * Returns the selected action, or null if cancelled. */ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { - header(`[${task.kind}] ${task.name}`); + header(formatTaskStatusLabel(task)); info(` Created: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); @@ -72,7 +73,7 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise { - header(`[${task.kind}] ${task.name}`); + header(formatTaskStatusLabel(task)); info(` Failed at: ${task.createdAt}`); if (task.content) { info(` ${task.content}`); @@ -129,12 +130,12 @@ export async function listTasks( }; }), ...pendingTasks.map((task, idx) => ({ - label: `[pending] ${task.name}`, + label: formatTaskStatusLabel(task), value: `pending:${idx}`, description: task.content, })), ...failedTasks.map((task, idx) => ({ - label: `[failed] ${task.name}`, + label: formatTaskStatusLabel(task), value: `failed:${idx}`, description: task.content, })), diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index e26d415..2eb7609 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -20,6 +20,7 @@ import { mergeBranch, deleteBranch, } from './taskActions.js'; +import { formatTaskStatusLabel } from './taskStatusLabel.js'; export interface ListNonInteractiveOptions { enabled: boolean; @@ -56,11 +57,11 @@ function printNonInteractiveList( } for (const task of pendingTasks) { - info(`[pending] ${task.name} - ${task.content}`); + info(`${formatTaskStatusLabel(task)} - ${task.content}`); } for (const task of failedTasks) { - info(`[failed] ${task.name} - ${task.content}`); + info(`${formatTaskStatusLabel(task)} - ${task.content}`); } } diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts new file mode 100644 index 0000000..05bd1ae --- /dev/null +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -0,0 +1,10 @@ +import type { TaskListItem } from '../../../infra/task/index.js'; + +const TASK_STATUS_BY_KIND: Record = { + pending: 'running', + failed: 'failed', +}; + +export function formatTaskStatusLabel(task: TaskListItem): string { + return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`; +}