diff --git a/src/__tests__/StreamDisplay.test.ts b/src/__tests__/StreamDisplay.test.ts new file mode 100644 index 0000000..42bad7f --- /dev/null +++ b/src/__tests__/StreamDisplay.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for StreamDisplay progress info feature + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StreamDisplay, type ProgressInfo } from '../shared/ui/index.js'; + +describe('StreamDisplay', () => { + let consoleLogSpy: ReturnType; + let stdoutWriteSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + stdoutWriteSpy.mockRestore(); + }); + + describe('progress info display', () => { + const progressInfo: ProgressInfo = { + iteration: 3, + maxIterations: 10, + movementIndex: 1, + totalMovements: 4, + }; + + describe('showInit', () => { + it('should include progress info when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showInit('claude-3'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('(3/10) step 2/4') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Model: claude-3') + ); + }); + + it('should not include progress info when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showInit('claude-3'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Model: claude-3') + ); + // Should not contain progress format + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringMatching(/\(\d+\/\d+\) step \d+\/\d+/) + ); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showInit('claude-3'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe('showText', () => { + it('should include progress info in first text header when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showText('Hello'); + + // First call is blank line, second is the header + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('(3/10) step 2/4') + ); + }); + + it('should not include progress info in header when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showText('Hello'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('[test-agent]'); + expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/); + }); + + it('should output text content to stdout', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showText('Hello'); + + expect(stdoutWriteSpy).toHaveBeenCalledWith('Hello'); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showText('Hello'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); + }); + + describe('showThinking', () => { + it('should include progress info in thinking header when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('(3/10) step 2/4') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('thinking') + ); + }); + + it('should not include progress info in header when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('[test-agent]'); + expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('progress prefix format', () => { + it('should format progress as (iteration/max) step index/total', () => { + const progressInfo: ProgressInfo = { + iteration: 5, + maxIterations: 20, + movementIndex: 2, + totalMovements: 6, + }; + const display = new StreamDisplay('agent', false, progressInfo); + display.showText('test'); + + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('(5/20) step 3/6'); + }); + + it('should convert 0-indexed movementIndex to 1-indexed display', () => { + const progressInfo: ProgressInfo = { + iteration: 1, + maxIterations: 10, + movementIndex: 0, // First movement (0-indexed) + totalMovements: 4, + }; + const display = new StreamDisplay('agent', false, progressInfo); + display.showText('test'); + + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('step 1/4'); // Should display as 1-indexed + }); + }); +}); diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 49036e7..bd05b25 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -275,6 +275,7 @@ describe('addTask', () => { // Given: issue reference "#99" const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.'; mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); @@ -288,6 +289,9 @@ describe('addTask', () => { // Then: resolveIssueTask was called expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); + // Then: determinePiece was called for piece selection + expect(mockDeterminePiece).toHaveBeenCalledWith(testDir); + // Then: task file created with issue text directly (no AI summarization) const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); expect(fs.existsSync(taskFile)).toBe(true); @@ -298,6 +302,7 @@ describe('addTask', () => { it('should proceed to worktree settings after issue fetch', async () => { // Given: issue with worktree enabled mockResolveIssueTask.mockReturnValue('Issue text'); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('issue-task'); mockConfirm.mockResolvedValue(true); mockPromptInput @@ -331,6 +336,7 @@ describe('addTask', () => { // Given: issue reference "#99" const issueText = 'Issue #99: Fix login timeout'; mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); @@ -344,6 +350,42 @@ describe('addTask', () => { expect(content).toContain('issue: 99'); }); + it('should include piece selection in task file when issue reference is used', async () => { + // Given: issue reference "#99" with non-default piece selection + const issueText = 'Issue #99: Fix login timeout'; + mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('review'); + mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); + mockConfirm.mockResolvedValue(false); + + // When + await addTask(testDir, '#99'); + + // Then: task file contains piece field + const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); + expect(fs.existsSync(taskFile)).toBe(true); + const content = fs.readFileSync(taskFile, 'utf-8'); + expect(content).toContain('piece: review'); + }); + + it('should cancel when piece selection returns null for issue reference', async () => { + // Given: issue fetched successfully but user cancels piece selection + const issueText = 'Issue #99: Fix login timeout'; + mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue(null); + + // When + await addTask(testDir, '#99'); + + // Then: no task file created (cancelled at piece selection) + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + expect(files.length).toBe(0); + + // Then: issue was fetched before cancellation + expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); + }); + describe('create_issue action', () => { it('should call createIssue when create_issue action is selected', async () => { // Given: interactive mode returns create_issue action diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 9854205..9718d1e 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -193,4 +193,51 @@ describe('loadGlobalConfig', () => { expect(config3.language).toBe('en'); expect(config3).not.toBe(config1); }); + + it('should load prevent_sleep config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\nprevent_sleep: true\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.preventSleep).toBe(true); + }); + + it('should save and reload prevent_sleep config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = true; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(true); + }); + + it('should save prevent_sleep: false when explicitly set', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = false; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(false); + }); + + it('should have undefined preventSleep by default', () => { + const config = loadGlobalConfig(); + expect(config.preventSleep).toBeUndefined(); + }); }); diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 1d15c30..5f41b50 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -414,4 +414,72 @@ describe('ParallelLogger', () => { expect(output[2]).toContain('A second'); }); }); + + describe('progress info display', () => { + it('should include progress info in prefix when provided', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a', 'step-b'], + writeFn, + progressInfo: { + iteration: 3, + maxIterations: 10, + }, + }); + + const prefix = logger.buildPrefix('step-a', 0); + expect(prefix).toContain('[step-a]'); + expect(prefix).toContain('(3/10)'); + expect(prefix).toContain('step 1/2'); // 0-indexed -> 1-indexed, 2 total sub-movements + }); + + it('should show correct step number for each sub-movement', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a', 'step-b', 'step-c'], + writeFn, + progressInfo: { + iteration: 5, + maxIterations: 20, + }, + }); + + const prefixA = logger.buildPrefix('step-a', 0); + const prefixB = logger.buildPrefix('step-b', 1); + const prefixC = logger.buildPrefix('step-c', 2); + + expect(prefixA).toContain('step 1/3'); + expect(prefixB).toContain('step 2/3'); + expect(prefixC).toContain('step 3/3'); + }); + + it('should not include progress info when not provided', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a'], + writeFn, + }); + + const prefix = logger.buildPrefix('step-a', 0); + expect(prefix).toContain('[step-a]'); + expect(prefix).not.toMatch(/\(\d+\/\d+\)/); + expect(prefix).not.toMatch(/step \d+\/\d+/); + }); + + it('should include progress info in streamed output', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a'], + writeFn, + progressInfo: { + iteration: 2, + maxIterations: 5, + }, + }); + const handler = logger.createStreamHandler('step-a', 0); + + handler({ type: 'text', data: { text: 'Hello world\n' } } as StreamEvent); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[step-a]'); + expect(output[0]).toContain('(2/5) step 1/1'); + expect(output[0]).toContain('Hello world'); + }); + }); }); diff --git a/src/__tests__/sleep.test.ts b/src/__tests__/sleep.test.ts new file mode 100644 index 0000000..e7e821b --- /dev/null +++ b/src/__tests__/sleep.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ChildProcess } from 'node:child_process'; + +// Mock modules before importing the module under test +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('node:os', () => ({ + platform: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock the debug logger +vi.mock('../shared/utils/debug.js', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + enter: vi.fn(), + exit: vi.fn(), + }), +})); + +// Import after mocks are set up +const { spawn } = await import('node:child_process'); +const { platform } = await import('node:os'); +const { existsSync } = await import('node:fs'); +const { preventSleep, resetPreventSleepState } = await import('../shared/utils/sleep.js'); + +describe('preventSleep', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPreventSleepState(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should do nothing on non-darwin platforms', () => { + vi.mocked(platform).mockReturnValue('linux'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should do nothing on Windows', () => { + vi.mocked(platform).mockReturnValue('win32'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should spawn caffeinate on macOS when available', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/caffeinate', + ['-i', '-w', String(process.pid)], + { stdio: 'ignore', detached: true } + ); + expect(mockChild.unref).toHaveBeenCalled(); + }); + + it('should not spawn caffeinate if not found', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should check for caffeinate at /usr/bin/caffeinate', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(existsSync).toHaveBeenCalledWith('/usr/bin/caffeinate'); + }); + + it('should only spawn caffeinate once even when called multiple times', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + preventSleep(); + preventSleep(); + + expect(spawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index bd1976f..172f182 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -63,6 +63,8 @@ export interface GlobalConfig { pieceCategoriesFile?: string; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + preventSleep?: boolean; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c3645b0..d9ccda5 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -272,6 +272,8 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + prevent_sleep: z.boolean().optional(), }); /** Project config schema */ diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index ff2ff20..9acaa24 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -71,6 +71,10 @@ export class ParallelRunner { ? new ParallelLogger({ subMovementNames: subMovements.map((s) => s.name), parentOnStream: this.deps.engineOptions.onStream, + progressInfo: { + iteration: state.iteration, + maxIterations, + }, }) : undefined; diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index 48ae69a..562d903 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -12,6 +12,14 @@ import type { StreamCallback, StreamEvent } from '../types.js'; const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green const RESET = '\x1b[0m'; +/** Progress information for parallel logger */ +export interface ParallelProgressInfo { + /** Current iteration (1-indexed) */ + iteration: number; + /** Maximum iterations allowed */ + maxIterations: number; +} + export interface ParallelLoggerOptions { /** Sub-movement names (used to calculate prefix width) */ subMovementNames: string[]; @@ -19,6 +27,8 @@ export interface ParallelLoggerOptions { parentOnStream?: StreamCallback; /** Override process.stdout.write for testing */ writeFn?: (text: string) => void; + /** Progress information for display */ + progressInfo?: ParallelProgressInfo; } /** @@ -34,11 +44,15 @@ export class ParallelLogger { private readonly lineBuffers: Map = new Map(); private readonly parentOnStream?: StreamCallback; private readonly writeFn: (text: string) => void; + private readonly progressInfo?: ParallelProgressInfo; + private readonly totalSubMovements: number; constructor(options: ParallelLoggerOptions) { this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length)); this.parentOnStream = options.parentOnStream; this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); + this.progressInfo = options.progressInfo; + this.totalSubMovements = options.subMovementNames.length; for (const name of options.subMovementNames) { this.lineBuffers.set(name, ''); @@ -47,12 +61,20 @@ export class ParallelLogger { /** * Build the colored prefix string for a sub-movement. - * Format: `\x1b[COLORm[name]\x1b[0m` + padding spaces + * Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces */ buildPrefix(name: string, index: number): string { const color = COLORS[index % COLORS.length]; const padding = ' '.repeat(this.maxNameLength - name.length); - return `${color}[${name}]${RESET}${padding} `; + + let progressPart = ''; + if (this.progressInfo) { + const { iteration, maxIterations } = this.progressInfo; + // index is 0-indexed, display as 1-indexed for step number + progressPart = `(${iteration}/${maxIterations}) step ${index + 1}/${this.totalSubMovements} `; + } + + return `${color}[${name}]${RESET}${padding} ${progressPart}`; } /** diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 12a84e5..cb0e6a7 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -107,18 +107,14 @@ export async function saveTaskFromInteractive( * add command handler * * Flow: - * 1. ピース選択 - * 2. AI対話モードでタスクを詰める - * 3. 会話履歴からAIがタスク要約を生成 - * 4. 要約からファイル名をAIで生成 - * 5. ワークツリー/ブランチ設定 - * 6. YAMLファイル作成 + * A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 + * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 */ export async function addTask(cwd: string, task?: string): Promise { const tasksDir = path.join(cwd, '.takt', 'tasks'); fs.mkdirSync(tasksDir, { recursive: true }); - // 1. ピース選択(Issue参照以外の場合、対話モードの前に実施) + // ピース選択とタスク内容の決定 let taskContent: string; let issueNumber: number | undefined; let piece: string | undefined; @@ -138,6 +134,14 @@ export async function addTask(cwd: string, task?: string): Promise { info(`Failed to fetch issue ${task}: ${msg}`); return; } + + // ピース選択(issue取得成功後) + const pieceId = await determinePiece(cwd); + if (pieceId === null) { + info('Cancelled.'); + return; + } + piece = pieceId; } else { // ピース選択を先に行い、結果を対話モードに渡す const pieceId = await determinePiece(cwd); @@ -185,7 +189,7 @@ export async function addTask(cwd: string, task?: string): Promise { autoPr = await confirm('Auto-create PR?', false); } - // 4. YAMLファイル作成 + // YAMLファイル作成 const filePath = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index d39790a..6e33419 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -46,7 +46,7 @@ import { type NdjsonInteractiveStart, type NdjsonInteractiveEnd, } from '../../../infra/fs/index.js'; -import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js'; +import { createLogger, notifySuccess, notifyError, preventSleep } from '../../../shared/utils/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { getLabel } from '../../../shared/i18n/index.js'; @@ -141,7 +141,13 @@ export async function executePiece( // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; - const currentProvider = loadGlobalConfig().provider ?? 'claude'; + const globalConfig = loadGlobalConfig(); + const currentProvider = globalConfig.provider ?? 'claude'; + + // Prevent macOS idle sleep if configured + if (globalConfig.preventSleep) { + preventSleep(); + } const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd, currentProvider) : loadAgentSessions(projectCwd, currentProvider); @@ -273,8 +279,17 @@ export async function executePiece( log.debug('Step instruction', instruction); } + // Find movement index for progress display + const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name); + const totalMovements = pieceConfig.movements.length; + // Use quiet mode from CLI (already resolved CLI flag + config in preAction) - displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode()); + displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode(), { + iteration, + maxIterations: pieceConfig.maxIterations, + movementIndex: movementIndex >= 0 ? movementIndex : 0, + totalMovements, + }); // Write step_start record to NDJSON log const record: NdjsonStepStart = { diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 6597066..d6d4cdc 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -89,6 +89,7 @@ export class GlobalConfigManager { bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, branchNameStrategy: parsed.branch_name_strategy, + preventSleep: parsed.prevent_sleep, }; this.cachedConfig = config; return config; @@ -151,6 +152,9 @@ export class GlobalConfigManager { if (config.branchNameStrategy) { raw.branch_name_strategy = config.branchNameStrategy; } + if (config.preventSleep !== undefined) { + raw.prevent_sleep = config.preventSleep; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); } diff --git a/src/shared/ui/StreamDisplay.ts b/src/shared/ui/StreamDisplay.ts index d54532e..53308be 100644 --- a/src/shared/ui/StreamDisplay.ts +++ b/src/shared/ui/StreamDisplay.ts @@ -13,6 +13,18 @@ import chalk from 'chalk'; import type { StreamEvent, StreamCallback } from '../../core/piece/index.js'; import { truncate } from './LogManager.js'; +/** Progress information for stream display */ +export interface ProgressInfo { + /** Current iteration (1-indexed) */ + iteration: number; + /** Maximum iterations allowed */ + maxIterations: number; + /** Current movement index within piece (0-indexed) */ + movementIndex: number; + /** Total number of movements in piece */ + totalMovements: number; +} + /** Stream display manager for real-time Claude output */ export class StreamDisplay { private lastToolUse: string | null = null; @@ -32,13 +44,30 @@ export class StreamDisplay { private spinnerFrame = 0; constructor( - private agentName = 'Claude', - private quiet = false, + private agentName: string, + private quiet: boolean, + private progressInfo?: ProgressInfo, ) {} + /** + * Build progress prefix string for display. + * Format: `(iteration/maxIterations) step movementIndex/totalMovements` + * Example: `(3/10) step 2/4` + */ + private buildProgressPrefix(): string { + if (!this.progressInfo) { + return ''; + } + const { iteration, maxIterations, movementIndex, totalMovements } = this.progressInfo; + // movementIndex is 0-indexed, display as 1-indexed + return `(${iteration}/${maxIterations}) step ${movementIndex + 1}/${totalMovements}`; + } + showInit(model: string): void { if (this.quiet) return; - console.log(chalk.gray(`[${this.agentName}] Model: ${model}`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.gray(`[${this.agentName}]${progressPart} Model: ${model}`)); } private startToolSpinner(tool: string, inputPreview: string): void { @@ -140,7 +169,9 @@ export class StreamDisplay { if (this.isFirstThinking) { console.log(); - console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.magenta(`💭 [${this.agentName}]${progressPart} thinking:`)); this.isFirstThinking = false; } process.stdout.write(chalk.gray.italic(thinking)); @@ -164,7 +195,9 @@ export class StreamDisplay { if (this.isFirstText) { console.log(); - console.log(chalk.cyan(`[${this.agentName}]:`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.cyan(`[${this.agentName}]${progressPart}:`)); this.isFirstText = false; } process.stdout.write(text); diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 9e0e2be..22df098 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -28,4 +28,4 @@ export { export { Spinner } from './Spinner.js'; -export { StreamDisplay } from './StreamDisplay.js'; +export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js'; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1f49ef9..1eb7ab4 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -6,6 +6,7 @@ export * from './debug.js'; export * from './error.js'; export * from './notification.js'; export * from './reportDir.js'; +export * from './sleep.js'; export * from './slug.js'; export * from './text.js'; export * from './types.js'; diff --git a/src/shared/utils/sleep.ts b/src/shared/utils/sleep.ts new file mode 100644 index 0000000..0c2ec6d --- /dev/null +++ b/src/shared/utils/sleep.ts @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:os'; +import { existsSync } from 'node:fs'; +import { createLogger } from './debug.js'; + +const log = createLogger('sleep'); + +let caffeinateStarted = false; + +/** + * takt実行中のmacOSアイドルスリープを防止する。 + * 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。 + */ +export function preventSleep(): void { + if (caffeinateStarted) { + return; + } + + if (platform() !== 'darwin') { + return; + } + + const caffeinatePath = '/usr/bin/caffeinate'; + if (!existsSync(caffeinatePath)) { + log.info('caffeinate not found, sleep prevention disabled'); + return; + } + + const child = spawn(caffeinatePath, ['-i', '-w', String(process.pid)], { + stdio: 'ignore', + detached: true, + }); + + child.unref(); + + caffeinateStarted = true; + + log.debug('Started caffeinate for sleep prevention', { pid: child.pid }); +} + +/** + * テスト用: caffeinateStarted フラグをリセットする + */ +export function resetPreventSleepState(): void { + caffeinateStarted = false; +}