takt/src/interactive/handlers.ts
2026-01-25 15:16:27 +09:00

219 lines
7.2 KiB
TypeScript

/**
* Interactive handlers for user questions and input
*
* Handles AskUserQuestion tool responses and user input prompts
* during workflow execution.
*/
import chalk from 'chalk';
import type { InputHistoryManager } from './input.js';
import { multiLineQuestion, createReadlineInterface } from './input.js';
import type { AskUserQuestionInput, AskUserQuestionHandler } from '../claude/process.js';
import { runAgent } from '../agents/runner.js';
import { info } from '../utils/ui.js';
/**
* Create a handler that uses another agent to answer questions.
* This allows automatic question answering by delegating to a specified agent.
*/
export function createAgentAnswerHandler(
answerAgentName: string,
cwd: string
): AskUserQuestionHandler {
return async (input: AskUserQuestionInput): Promise<Record<string, string>> => {
const answers: Record<string, string> = {};
console.log();
console.log(chalk.magenta('━'.repeat(60)));
console.log(chalk.magenta.bold(`🤖 ${answerAgentName} が質問に回答します`));
console.log(chalk.magenta('━'.repeat(60)));
for (let i = 0; i < input.questions.length; i++) {
const q = input.questions[i];
if (!q) continue;
const questionKey = `q${i}`;
// Build a prompt for the answer agent
let prompt = `以下の質問に回答してください。回答のみを出力してください。\n\n`;
prompt += `質問: ${q.question}\n`;
if (q.options && q.options.length > 0) {
prompt += `\n選択肢:\n`;
q.options.forEach((opt, idx) => {
prompt += `${idx + 1}. ${opt.label}`;
if (opt.description) {
prompt += ` - ${opt.description}`;
}
prompt += '\n';
});
prompt += `\n選択肢の番号またはラベルで回答してください。選択肢以外の回答も可能です。`;
}
console.log(chalk.gray(`質問: ${q.question}`));
try {
const response = await runAgent(answerAgentName, prompt, {
cwd,
// Don't use session for answer agent - each question is independent
});
// Extract the answer from agent response
const answerContent = response.content.trim();
// If the agent selected a numbered option, convert to label
const options = q.options;
if (options && options.length > 0) {
const num = parseInt(answerContent, 10);
if (num >= 1 && num <= options.length) {
const selectedOption = options[num - 1];
answers[questionKey] = selectedOption?.label ?? answerContent;
} else {
// Check if agent replied with exact label
const matchedOption = options.find(
opt => opt.label.toLowerCase() === answerContent.toLowerCase()
);
if (matchedOption) {
answers[questionKey] = matchedOption.label;
} else {
answers[questionKey] = answerContent;
}
}
} else {
answers[questionKey] = answerContent;
}
console.log(chalk.green(`回答: ${answers[questionKey]}`));
} catch (err) {
console.log(chalk.red(`エージェントエラー: ${err instanceof Error ? err.message : String(err)}`));
// Fall back to empty answer on error
answers[questionKey] = '';
}
console.log();
}
console.log(chalk.magenta('━'.repeat(60)));
console.log();
return answers;
};
}
/**
* Handle AskUserQuestion tool from Claude Code.
* Displays questions to the user and collects their answers.
*/
export function createAskUserQuestionHandler(
rl: ReturnType<typeof createReadlineInterface>,
historyManager: InputHistoryManager
): AskUserQuestionHandler {
return async (input: AskUserQuestionInput): Promise<Record<string, string>> => {
const answers: Record<string, string> = {};
console.log();
console.log(chalk.blue('━'.repeat(60)));
console.log(chalk.blue.bold('❓ Claude Code からの質問'));
console.log(chalk.blue('━'.repeat(60)));
console.log();
for (let i = 0; i < input.questions.length; i++) {
const q = input.questions[i];
if (!q) continue;
const questionKey = `q${i}`;
// Show the question
if (q.header) {
console.log(chalk.cyan.bold(`[${q.header}]`));
}
console.log(chalk.white(q.question));
// Show options if available
const options = q.options;
if (options && options.length > 0) {
console.log();
options.forEach((opt, idx) => {
const label = chalk.yellow(` ${idx + 1}. ${opt.label}`);
const desc = opt.description ? chalk.gray(` - ${opt.description}`) : '';
console.log(label + desc);
});
console.log(chalk.gray(` ${options.length + 1}. その他(自由入力)`));
console.log();
// Prompt for selection
const answer = await new Promise<string>((resolve) => {
multiLineQuestion(rl, {
promptStr: chalk.magenta('選択> '),
onCtrlC: () => {
resolve('');
return true;
},
historyManager,
}).then(resolve).catch(() => resolve(''));
});
const trimmed = answer.trim();
const num = parseInt(trimmed, 10);
if (num >= 1 && num <= options.length) {
// User selected an option
const selectedOption = options[num - 1];
answers[questionKey] = selectedOption?.label ?? '';
} else if (num === options.length + 1 || isNaN(num)) {
// User selected "Other" or entered free text
if (isNaN(num) && trimmed !== '') {
answers[questionKey] = trimmed;
} else {
console.log(chalk.cyan('自由入力してください:'));
const freeAnswer = await new Promise<string>((resolve) => {
multiLineQuestion(rl, {
promptStr: chalk.magenta('回答> '),
onCtrlC: () => {
resolve('');
return true;
},
historyManager,
}).then(resolve).catch(() => resolve(''));
});
answers[questionKey] = freeAnswer.trim();
}
} else {
answers[questionKey] = trimmed;
}
} else {
// No options, free text input
console.log();
const answer = await new Promise<string>((resolve) => {
multiLineQuestion(rl, {
promptStr: chalk.magenta('回答> '),
onCtrlC: () => {
resolve('');
return true;
},
historyManager,
}).then(resolve).catch(() => resolve(''));
});
answers[questionKey] = answer.trim();
}
console.log();
}
console.log(chalk.blue('━'.repeat(60)));
console.log();
return answers;
};
}
/**
* Create a handler for sacrifice mode that auto-skips all questions.
*/
export function createSacrificeModeQuestionHandler(): AskUserQuestionHandler {
return async (_input: AskUserQuestionInput): Promise<Record<string, string>> => {
info('[SACRIFICE MODE] Auto-skipping AskUserQuestion');
return {};
};
}