add-task時に改行を許可。worktreeで失敗する問題を修正
This commit is contained in:
parent
2c738d8009
commit
df22e4c33b
@ -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)
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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++;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user