add-task時に改行を許可。worktreeで失敗する問題を修正

This commit is contained in:
nrslib 2026-01-28 17:43:39 +09:00
parent 2c738d8009
commit df22e4c33b
7 changed files with 136 additions and 9 deletions

View File

@ -3,12 +3,14 @@
*/ */
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Readable } from 'node:stream';
import chalk from 'chalk'; import chalk from 'chalk';
import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js'; import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js';
import { import {
renderMenu, renderMenu,
countRenderedLines, countRenderedLines,
handleKeyInput, handleKeyInput,
readMultilineFromStream,
} from '../prompt/index.js'; } from '../prompt/index.js';
import { isFullWidth, getDisplayWidth, truncateText } from '../utils/text.js'; import { isFullWidth, getDisplayWidth, truncateText } from '../utils/text.js';
@ -424,6 +426,46 @@ describe('prompt', () => {
}); });
}); });
describe('readMultilineFromStream', () => {
it('should return null when first line is empty (cancel)', async () => {
const input = Readable.from(['\n']);
const result = await readMultilineFromStream(input);
expect(result).toBeNull();
});
it('should return single line when followed by empty line', async () => {
const input = Readable.from(['hello world\n\n']);
const result = await readMultilineFromStream(input);
expect(result).toBe('hello world');
});
it('should return multiple lines joined by newline', async () => {
const input = Readable.from(['line 1\nline 2\nline 3\n\n']);
const result = await readMultilineFromStream(input);
expect(result).toBe('line 1\nline 2\nline 3');
});
it('should trim leading and trailing whitespace from the joined result', async () => {
const input = Readable.from([' hello \n world \n\n']);
const result = await readMultilineFromStream(input);
// .trim() is applied to the joined string, so leading spaces on first line are trimmed
expect(result).toBe('hello \n world');
});
it('should handle stream close without empty line (Ctrl+C)', async () => {
// Stream ends without empty line terminator
const input = Readable.from(['some content\n']);
const result = await readMultilineFromStream(input);
expect(result).toBe('some content');
});
it('should return null when stream closes with no input', async () => {
const input = Readable.from([]);
const result = await readMultilineFromStream(input);
expect(result).toBeNull();
});
});
describe('selectOptionWithDefault cancel behavior', () => { describe('selectOptionWithDefault cancel behavior', () => {
it('handleKeyInput should return cancel with optionCount when hasCancelOption is true', () => { it('handleKeyInput should return cancel with optionCount when hasCancelOption is true', () => {
// Simulates ESC key press with cancel option enabled (as selectOptionWithDefault now does) // Simulates ESC key press with cancel option enabled (as selectOptionWithDefault now does)

View File

@ -8,6 +8,9 @@ import { executeClaudeCli, type ClaudeSpawnOptions, type StreamCallback, type Pe
import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk'; import type { AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
import type { AgentResponse, Status, PermissionMode } from '../models/types.js'; import type { AgentResponse, Status, PermissionMode } from '../models/types.js';
import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js'; import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js';
import { createLogger } from '../utils/debug.js';
const log = createLogger('client');
/** Options for calling Claude */ /** Options for calling Claude */
export interface ClaudeCallOptions { export interface ClaudeCallOptions {
@ -125,12 +128,17 @@ export async function callClaude(
const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentType); const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentType);
const status = determineStatus(result, patterns); const status = determineStatus(result, patterns);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentType, error: result.error });
}
return { return {
agent: agentType, agent: agentType,
status, status,
content: result.content, content: result.content,
timestamp: new Date(), timestamp: new Date(),
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error,
}; };
} }
@ -160,12 +168,17 @@ export async function callClaudeCustom(
const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentName); const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentName);
const status = determineStatus(result, patterns); const status = determineStatus(result, patterns);
if (!result.success && result.error) {
log.error('Agent query failed', { agent: agentName, error: result.error });
}
return { return {
agent: agentName, agent: agentName,
status, status,
content: result.content, content: result.content,
timestamp: new Date(), timestamp: new Date(),
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error,
}; };
} }
@ -206,11 +219,16 @@ export async function callClaudeSkill(
const result = await executeClaudeCli(fullPrompt, spawnOptions); const result = await executeClaudeCli(fullPrompt, spawnOptions);
if (!result.success && result.error) {
log.error('Skill query failed', { skill: skillName, error: result.error });
}
return { return {
agent: `skill:${skillName}`, agent: `skill:${skillName}`,
status: result.success ? 'done' : 'blocked', status: result.success ? 'done' : 'blocked',
content: result.content, content: result.content,
timestamp: new Date(), timestamp: new Date(),
sessionId: result.sessionId, sessionId: result.sessionId,
error: result.error,
}; };
} }

View File

@ -8,7 +8,7 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml'; import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm, selectOption } from '../prompt/index.js'; import { promptInput, promptMultilineInput, confirm, selectOption } from '../prompt/index.js';
import { success, info } from '../utils/ui.js'; import { success, info } from '../utils/ui.js';
import { slugify } from '../utils/slug.js'; import { slugify } from '../utils/slug.js';
import { createLogger } from '../utils/debug.js'; import { createLogger } from '../utils/debug.js';
@ -55,8 +55,8 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
// Argument mode: task content provided directly // Argument mode: task content provided directly
taskContent = args.join(' '); taskContent = args.join(' ');
} else { } else {
// Interactive mode // Interactive mode (multiline: empty line to finish)
const input = await promptInput('Task content'); const input = await promptMultilineInput('Task content');
if (!input) { if (!input) {
info('Cancelled.'); info('Cancelled.');
return; return;
@ -110,8 +110,9 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
taskData.workflow = workflow; taskData.workflow = workflow;
} }
// Write YAML filea // Write YAML file (use first line for filename to keep it short)
const filename = generateFilename(tasksDir, taskContent); const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = generateFilename(tasksDir, firstLine);
const filePath = path.join(tasksDir, filename); const filePath = path.join(tasksDir, filename);
const yamlContent = stringifyYaml(taskData); const yamlContent = stringifyYaml(taskData);
fs.writeFileSync(filePath, yamlContent, 'utf-8'); fs.writeFileSync(filePath, yamlContent, 'utf-8');

View File

@ -97,10 +97,17 @@ export async function executeWorkflow(
}; };
// Load saved agent sessions for continuity (from project root) // Load saved agent sessions for continuity (from project root)
const savedSessions = loadAgentSessions(projectCwd); // When running in a worktree (cwd !== projectCwd), skip session resume because
// Claude Code sessions are stored per-cwd in ~/.claude/projects/{encoded-path}/
// and sessions from the main project dir can't be resumed in a worktree dir.
const isWorktree = cwd !== projectCwd;
const savedSessions = isWorktree ? {} : loadAgentSessions(projectCwd);
// Session update handler - persist session IDs when they change (to project root) // Session update handler - persist session IDs when they change (to project root)
const sessionUpdateHandler = (agentName: string, agentSessionId: string): void => { // Skip persisting worktree sessions since they can't be reused across different worktrees.
const sessionUpdateHandler = isWorktree
? undefined
: (agentName: string, agentSessionId: string): void => {
updateAgentSession(projectCwd, agentName, agentSessionId); updateAgentSession(projectCwd, agentName, agentSessionId);
}; };
@ -169,6 +176,7 @@ export async function executeWorkflow(
status: response.status, status: response.status,
contentLength: response.content.length, contentLength: response.content.length,
sessionId: response.sessionId, sessionId: response.sessionId,
error: response.error,
}); });
if (displayRef.current) { if (displayRef.current) {
displayRef.current.flush(); displayRef.current.flush();
@ -176,6 +184,9 @@ export async function executeWorkflow(
} }
console.log(); console.log();
status('Status', response.status); status('Status', response.status);
if (response.error) {
error(`Error: ${response.error}`);
}
if (response.sessionId) { if (response.sessionId) {
status('Session', response.sessionId); status('Session', response.sessionId);
} }

View File

@ -35,6 +35,8 @@ export interface AgentResponse {
content: string; content: string;
timestamp: Date; timestamp: Date;
sessionId?: string; sessionId?: string;
/** Error message when the query failed (e.g., API error, rate limit) */
error?: string;
} }
/** Session state for workflow execution */ /** Session state for workflow execution */

View File

@ -296,6 +296,57 @@ export async function promptInput(message: string): Promise<string | null> {
}); });
} }
/**
* Read multiline input from a readable stream.
* An empty line finishes input. If the first line is empty, returns null.
* Exported for testing.
*/
export function readMultilineFromStream(input: NodeJS.ReadableStream): Promise<string | null> {
const lines: string[] = [];
const rl = readline.createInterface({ input });
return new Promise((resolve) => {
let resolved = false;
rl.on('line', (line) => {
if (line === '' && lines.length > 0) {
resolved = true;
rl.close();
const result = lines.join('\n').trim();
resolve(result || null);
return;
}
if (line === '' && lines.length === 0) {
resolved = true;
rl.close();
resolve(null);
return;
}
lines.push(line);
});
rl.on('close', () => {
if (!resolved) {
resolve(lines.length > 0 ? lines.join('\n').trim() : null);
}
});
});
}
/**
* Prompt user for multiline text input.
* Each line is entered with Enter. An empty line finishes input.
* If the first line is empty, returns null (cancel).
* @returns Multiline text or null if cancelled
*/
export async function promptMultilineInput(message: string): Promise<string | null> {
console.log(chalk.green(`${message} (empty line to finish):`));
process.stdout.write(chalk.gray('> '));
return readMultilineFromStream(process.stdin);
}
/** /**
* Prompt user to select from a list of options with a default value. * Prompt user to select from a list of options with a default value.
* Uses cursor navigation. Enter immediately selects the default. * Uses cursor navigation. Enter immediately selects the default.

View File

@ -22,6 +22,7 @@ export interface SessionLog {
status: string; status: string;
timestamp: string; timestamp: string;
content: string; content: string;
error?: string;
}>; }>;
} }
@ -83,6 +84,7 @@ export function addToSessionLog(
status: response.status, status: response.status,
timestamp: response.timestamp.toISOString(), timestamp: response.timestamp.toISOString(),
content: response.content, content: response.content,
...(response.error ? { error: response.error } : {}),
}); });
log.iterations++; log.iterations++;
} }