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:
commit
00ffb84a87
177
src/__tests__/StreamDisplay.test.ts
Normal file
177
src/__tests__/StreamDisplay.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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
114
src/__tests__/sleep.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -28,4 +28,4 @@ export {
|
||||
|
||||
export { Spinner } from './Spinner.js';
|
||||
|
||||
export { StreamDisplay } from './StreamDisplay.js';
|
||||
export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js';
|
||||
|
||||
@ -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
46
src/shared/utils/sleep.ts
Normal 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;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user