diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 7396187..ae79fbe 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -18,6 +18,7 @@ vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), blankLine: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b7ed5d6..ca3dda0 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), + withProgress: vi.fn(async (_start, _done, operation) => operation()), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 8ebb3cc..a8fbfa5 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -28,14 +28,23 @@ vi.mock('../infra/task/summarize.js', () => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - header: vi.fn(), - status: vi.fn(), - setLogLevel: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + info, + error: vi.fn(), + success: vi.fn(), + header: vi.fn(), + status: vi.fn(), + setLogLevel: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index e845b1a..60d49d0 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -40,14 +40,23 @@ vi.mock('../infra/task/summarize.js', async (importOriginal) => ({ summarizeTaskName: vi.fn(), })); -vi.mock('../shared/ui/index.js', () => ({ - header: vi.fn(), - info: vi.fn(), - error: vi.fn(), - success: vi.fn(), - status: vi.fn(), - blankLine: vi.fn(), -})); +vi.mock('../shared/ui/index.js', () => { + const info = vi.fn(); + return { + header: vi.fn(), + info, + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + withProgress: vi.fn(async (start, done, operation) => { + info(start); + const result = await operation(); + info(typeof done === 'function' ? done(result) : done); + return result; + }), + }; +}); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 2bf757e..4339441 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -5,7 +5,7 @@ * pipeline mode, or interactive mode. */ -import { info, error } from '../../shared/ui/index.js'; +import { info, error, withProgress } from '../../shared/ui/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -35,23 +35,24 @@ import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from ' * Returns resolved issues and the formatted task text for interactive mode. * Throws on gh CLI unavailability or fetch failure. */ -function resolveIssueInput( +async function resolveIssueInput( issueOption: number | undefined, task: string | undefined, -): { issues: GitHubIssue[]; initialInput: string } | null { +): Promise<{ issues: GitHubIssue[]; initialInput: string } | null> { if (issueOption) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); } - const issue = fetchIssue(issueOption); - info(`GitHub Issue fetched: #${issue.number} ${issue.title}`); + const issue = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssue) => `GitHub Issue fetched: #${fetchedIssue.number} ${fetchedIssue.title}`, + async () => fetchIssue(issueOption), + ); return { issues: [issue], initialInput: formatIssueAsTask(issue) }; } if (task && isDirectTask(task)) { - info('Fetching GitHub Issue...'); const ghStatus = checkGhCli(); if (!ghStatus.available) { throw new Error(ghStatus.error); @@ -61,8 +62,11 @@ function resolveIssueInput( if (issueNumbers.length === 0) { throw new Error(`Invalid issue reference: ${task}`); } - const issues = issueNumbers.map((n) => fetchIssue(n)); - info(`GitHub Issues fetched: ${issues.map((issue) => `#${issue.number}`).join(', ')}`); + const issues = await withProgress( + 'Fetching GitHub Issue...', + (fetchedIssues) => `GitHub Issues fetched: ${fetchedIssues.map((issue) => `#${issue.number}`).join(', ')}`, + async () => issueNumbers.map((n) => fetchIssue(n)), + ); return { issues, initialInput: issues.map(formatIssueAsTask).join('\n\n---\n\n') }; } @@ -118,7 +122,7 @@ export async function executeDefaultAction(task?: string): Promise { let initialInput: string | undefined = task; try { - const issueResult = resolveIssueInput(opts.issue as number | undefined, task); + const issueResult = await resolveIssueInput(opts.issue as number | undefined, task); if (issueResult) { selectOptions.issues = issueResult.issues; initialInput = issueResult.initialInput; diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 13d348e..643c3e5 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -7,7 +7,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; -import { success, info, error } from '../../../shared/ui/index.js'; +import { success, info, error, withProgress } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { determinePiece } from '../execute/selectAndExecute.js'; import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js'; @@ -177,13 +177,16 @@ export async function addTask(cwd: string, task?: string): Promise { if (isIssueReference(trimmedTask)) { // Issue reference: fetch issue and use directly as task content - info('Fetching GitHub Issue...'); try { - taskContent = resolveIssueTask(trimmedTask); const numbers = parseIssueNumbers([trimmedTask]); + const primaryIssueNumber = numbers[0]; + taskContent = await withProgress( + 'Fetching GitHub Issue...', + primaryIssueNumber ? `GitHub Issue fetched: #${primaryIssueNumber}` : 'GitHub Issue fetched', + async () => resolveIssueTask(trimmedTask), + ); if (numbers.length > 0) { issueNumber = numbers[0]; - info(`GitHub Issue fetched: #${issueNumber}`); } } catch (e) { const msg = getErrorMessage(e); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 3f6ea7f..d36c439 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -4,7 +4,7 @@ import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; -import { info } from '../../../shared/ui/index.js'; +import { info, withProgress } from '../../../shared/ui/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { @@ -60,23 +60,27 @@ export async function resolveTaskExecution( if (data.worktree) { throwIfAborted(abortSignal); baseBranch = getCurrentBranch(defaultCwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); - info(`Branch name generated: ${taskSlug}`); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task.content, { cwd: defaultCwd }), + ); throwIfAborted(abortSignal); - info('Creating clone...'); - const result = createSharedClone(defaultCwd, { - worktree: data.worktree, - branch: data.branch, - taskSlug, - issueNumber: data.issue, - }); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(defaultCwd, { + worktree: data.worktree, + branch: data.branch, + taskSlug, + issueNumber: data.issue, + }), + ); throwIfAborted(abortSignal); execCwd = result.path; branch = result.branch; isWorktree = true; - info(`Clone created: ${result.path} (branch: ${result.branch})`); } const execPiece = data.piece || defaultPiece; diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index f41ec72..5816921 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -19,7 +19,7 @@ import { import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import { info, error, success } from '../../../shared/ui/index.js'; +import { info, error, success, withProgress } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js'; import { executeTask } from './taskExecution.js'; @@ -113,16 +113,20 @@ export async function confirmAndCreateWorktree( const baseBranch = getCurrentBranch(cwd); - info('Generating branch name...'); - const taskSlug = await summarizeTaskName(task, { cwd }); - info(`Branch name generated: ${taskSlug}`); + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task, { cwd }), + ); - info('Creating clone...'); - const result = createSharedClone(cwd, { - worktree: true, - taskSlug, - }); - info(`Clone created: ${result.path} (branch: ${result.branch})`); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(cwd, { + worktree: true, + taskSlug, + }), + ); return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; } diff --git a/src/shared/ui/Progress.ts b/src/shared/ui/Progress.ts new file mode 100644 index 0000000..bbb90a0 --- /dev/null +++ b/src/shared/ui/Progress.ts @@ -0,0 +1,17 @@ +import { info } from './LogManager.js'; + +export type ProgressCompletionMessage = string | ((result: T) => string); + +export async function withProgress( + startMessage: string, + completionMessage: ProgressCompletionMessage, + operation: () => Promise, +): Promise { + info(startMessage); + const result = await operation(); + const message = typeof completionMessage === 'function' + ? completionMessage(result) + : completionMessage; + info(message); + return result; +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index faec026..7968996 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -31,3 +31,5 @@ export { Spinner } from './Spinner.js'; export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; export { TaskPrefixWriter } from './TaskPrefixWriter.js'; + +export { withProgress, type ProgressCompletionMessage } from './Progress.js';