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 { Readable } from 'node:stream';
|
||||
import chalk from 'chalk';
|
||||
import type { SelectOptionItem, KeyInputResult } from '../prompt/index.js';
|
||||
import {
|
||||
renderMenu,
|
||||
countRenderedLines,
|
||||
handleKeyInput,
|
||||
readMultilineFromStream,
|
||||
} from '../prompt/index.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', () => {
|
||||
it('handleKeyInput should return cancel with optionCount when hasCancelOption is true', () => {
|
||||
// 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 { AgentResponse, Status, PermissionMode } from '../models/types.js';
|
||||
import { GENERIC_STATUS_PATTERNS } from '../models/schemas.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
|
||||
const log = createLogger('client');
|
||||
|
||||
/** Options for calling Claude */
|
||||
export interface ClaudeCallOptions {
|
||||
@ -125,12 +128,17 @@ export async function callClaude(
|
||||
const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentType);
|
||||
const status = determineStatus(result, patterns);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentType, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentType,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
@ -160,12 +168,17 @@ export async function callClaudeCustom(
|
||||
const patterns = options.statusPatterns || getBuiltinStatusPatterns(agentName);
|
||||
const status = determineStatus(result, patterns);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Agent query failed', { agent: agentName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: agentName,
|
||||
status,
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
@ -206,11 +219,16 @@ export async function callClaudeSkill(
|
||||
|
||||
const result = await executeClaudeCli(fullPrompt, spawnOptions);
|
||||
|
||||
if (!result.success && result.error) {
|
||||
log.error('Skill query failed', { skill: skillName, error: result.error });
|
||||
}
|
||||
|
||||
return {
|
||||
agent: `skill:${skillName}`,
|
||||
status: result.success ? 'done' : 'blocked',
|
||||
content: result.content,
|
||||
timestamp: new Date(),
|
||||
sessionId: result.sessionId,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
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 { slugify } from '../utils/slug.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
|
||||
taskContent = args.join(' ');
|
||||
} else {
|
||||
// Interactive mode
|
||||
const input = await promptInput('Task content');
|
||||
// Interactive mode (multiline: empty line to finish)
|
||||
const input = await promptMultilineInput('Task content');
|
||||
if (!input) {
|
||||
info('Cancelled.');
|
||||
return;
|
||||
@ -110,8 +110,9 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
|
||||
taskData.workflow = workflow;
|
||||
}
|
||||
|
||||
// Write YAML filea
|
||||
const filename = generateFilename(tasksDir, taskContent);
|
||||
// Write YAML file (use first line for filename to keep it short)
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = generateFilename(tasksDir, firstLine);
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||
|
||||
@ -97,12 +97,19 @@ export async function executeWorkflow(
|
||||
};
|
||||
|
||||
// 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)
|
||||
const sessionUpdateHandler = (agentName: string, agentSessionId: string): void => {
|
||||
updateAgentSession(projectCwd, agentName, agentSessionId);
|
||||
};
|
||||
// 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);
|
||||
};
|
||||
|
||||
const iterationLimitHandler = async (
|
||||
request: IterationLimitRequest
|
||||
@ -169,6 +176,7 @@ export async function executeWorkflow(
|
||||
status: response.status,
|
||||
contentLength: response.content.length,
|
||||
sessionId: response.sessionId,
|
||||
error: response.error,
|
||||
});
|
||||
if (displayRef.current) {
|
||||
displayRef.current.flush();
|
||||
@ -176,6 +184,9 @@ export async function executeWorkflow(
|
||||
}
|
||||
console.log();
|
||||
status('Status', response.status);
|
||||
if (response.error) {
|
||||
error(`Error: ${response.error}`);
|
||||
}
|
||||
if (response.sessionId) {
|
||||
status('Session', response.sessionId);
|
||||
}
|
||||
|
||||
@ -35,6 +35,8 @@ export interface AgentResponse {
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
sessionId?: string;
|
||||
/** Error message when the query failed (e.g., API error, rate limit) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 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.
|
||||
* Uses cursor navigation. Enter immediately selects the default.
|
||||
|
||||
@ -22,6 +22,7 @@ export interface SessionLog {
|
||||
status: string;
|
||||
timestamp: string;
|
||||
content: string;
|
||||
error?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
@ -83,6 +84,7 @@ export function addToSessionLog(
|
||||
status: response.status,
|
||||
timestamp: response.timestamp.toISOString(),
|
||||
content: response.content,
|
||||
...(response.error ? { error: response.error } : {}),
|
||||
});
|
||||
log.iterations++;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user