Merge branch 'develop' into takt/#98/github-issue-98-pr-no-wo-ni-sh

# Conflicts:
#	src/features/tasks/add/index.ts
This commit is contained in:
nrslib 2026-02-06 22:46:59 +09:00
commit 00ffb84a87
16 changed files with 600 additions and 19 deletions

View File

@ -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<typeof vi.spyOn>;
let stdoutWriteSpy: ReturnType<typeof vi.spyOn>;
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
});
});
});

View File

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

View File

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

View File

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

114
src/__tests__/sleep.test.ts Normal file
View File

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

View File

@ -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 */

View File

@ -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 */

View File

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

View File

@ -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<string, string> = 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}`;
}
/**

View File

@ -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<void> {
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<void> {
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<void> {
autoPr = await confirm('Auto-create PR?', false);
}
// 4. YAMLファイル作成
// YAMLファイル作成
const filePath = await saveTaskFile(cwd, taskContent, {
piece,
issue: issueNumber,

View File

@ -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 = {

View File

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

View File

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

View File

@ -28,4 +28,4 @@ export {
export { Spinner } from './Spinner.js';
export { StreamDisplay } from './StreamDisplay.js';
export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js';

View File

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

46
src/shared/utils/sleep.ts Normal file
View File

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