Skip to content

Commit 98e3db2

Browse files
committed
feat: handle AskUserQuestion tool with interactive UI in AI chat
Add support for the Claude SDK's AskUserQuestion tool so the AI can ask clarifying questions with clickable option buttons instead of showing raw tool output. Includes single-select, multi-select, and free-text "other" input. Answers are sent back to the SDK via a PreToolUse hook.
1 parent 4f92323 commit 98e3db2

4 files changed

Lines changed: 377 additions & 1 deletion

File tree

src-node/claude-code-agent.js

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@ let editorMcpServer = null;
4747
// Streaming throttle
4848
const TEXT_STREAM_THROTTLE_MS = 50;
4949

50+
// Pending question resolver — used by AskUserQuestion hook
51+
let _questionResolve = null;
52+
5053
const nodeConnector = global.createNodeConnector(CONNECTOR_ID, exports);
5154

5255
/**
@@ -186,11 +189,25 @@ exports.cancelQuery = async function () {
186189
currentAbortController = null;
187190
// Clear session so next query starts fresh instead of resuming a killed session
188191
currentSessionId = null;
192+
// Clear any pending question
193+
_questionResolve = null;
189194
return { success: true };
190195
}
191196
return { success: false };
192197
};
193198

199+
/**
200+
* Receive the user's answer to an AskUserQuestion prompt.
201+
* Called from browser via execPeer("answerQuestion", {answers}).
202+
*/
203+
exports.answerQuestion = async function (params) {
204+
if (_questionResolve) {
205+
_questionResolve(params);
206+
_questionResolve = null;
207+
}
208+
return { success: true };
209+
};
210+
194211
/**
195212
* Destroy the current session (clear session ID).
196213
*/
@@ -233,6 +250,7 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
233250
maxTurns: undefined,
234251
allowedTools: [
235252
"Read", "Edit", "Write", "Glob", "Grep",
253+
"AskUserQuestion",
236254
"mcp__phoenix-editor__getEditorState",
237255
"mcp__phoenix-editor__takeScreenshot",
238256
"mcp__phoenix-editor__execJsInLivePreview",
@@ -385,6 +403,48 @@ async function _runQuery(requestId, prompt, projectPath, model, signal, locale)
385403
};
386404
}
387405
]
406+
},
407+
{
408+
matcher: "AskUserQuestion",
409+
hooks: [
410+
async (input) => {
411+
console.log("[Phoenix AI] Intercepted AskUserQuestion");
412+
const questions = input.tool_input.questions || [];
413+
nodeConnector.triggerPeer("aiQuestion", {
414+
requestId: requestId,
415+
questions: questions
416+
});
417+
// Wait for the user's answer from the browser UI
418+
const answer = await new Promise((resolve, reject) => {
419+
_questionResolve = resolve;
420+
if (signal.aborted) {
421+
_questionResolve = null;
422+
reject(new Error("Aborted"));
423+
return;
424+
}
425+
const onAbort = () => {
426+
_questionResolve = null;
427+
reject(new Error("Aborted"));
428+
};
429+
signal.addEventListener("abort", onAbort, { once: true });
430+
});
431+
// Format answers as readable text for the AI
432+
let answerText = "";
433+
if (answer.answers) {
434+
const keys = Object.keys(answer.answers);
435+
keys.forEach(function (q) {
436+
answerText += "Q: " + q + "\nA: " + answer.answers[q] + "\n\n";
437+
});
438+
}
439+
return {
440+
hookSpecificOutput: {
441+
hookEventName: "PreToolUse",
442+
permissionDecision: "deny",
443+
permissionDecisionReason: answerText.trim() || "No answer provided"
444+
}
445+
};
446+
}
447+
]
388448
}
389449
]
390450
}

src/core-ai/AIChatPanel.js

Lines changed: 152 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ define(function (require, exports, module) {
140140
_nodeConnector.on("aiToolEdit", _onToolEdit);
141141
_nodeConnector.on("aiError", _onError);
142142
_nodeConnector.on("aiComplete", _onComplete);
143+
_nodeConnector.on("aiQuestion", _onQuestion);
143144

144145
// Check availability and render appropriate UI
145146
_checkAvailability();
@@ -641,7 +642,8 @@ define(function (require, exports, module) {
641642
"mcp__phoenix-editor__controlEditor": { icon: "fa-solid fa-code", color: "#6bc76b", label: Strings.AI_CHAT_TOOL_CONTROL_EDITOR },
642643
"mcp__phoenix-editor__resizeLivePreview": { icon: "fa-solid fa-arrows-left-right", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_RESIZE_PREVIEW },
643644
"mcp__phoenix-editor__wait": { icon: "fa-solid fa-hourglass-half", color: "#adb9bd", label: Strings.AI_CHAT_TOOL_WAIT },
644-
TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS }
645+
TodoWrite: { icon: "fa-solid fa-list-check", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_TASKS },
646+
AskUserQuestion: { icon: "fa-solid fa-circle-question", color: "#66bb6a", label: Strings.AI_CHAT_TOOL_QUESTION }
645647
};
646648

647649
function _onProgress(_event, data) {
@@ -1134,6 +1136,148 @@ define(function (require, exports, module) {
11341136
});
11351137
}
11361138

1139+
/**
1140+
* Handle an AskUserQuestion tool call — render interactive question UI.
1141+
*/
1142+
function _onQuestion(_event, data) {
1143+
const questions = data.questions || [];
1144+
if (!questions.length) {
1145+
return;
1146+
}
1147+
1148+
// Remove thinking indicator on first content
1149+
if (!_hasReceivedContent) {
1150+
_hasReceivedContent = true;
1151+
$messages.find(".ai-thinking").remove();
1152+
}
1153+
1154+
// Finalize current text segment so question appears after it
1155+
$messages.find(".ai-stream-target").removeClass("ai-stream-target");
1156+
_segmentText = "";
1157+
1158+
const answers = {};
1159+
const totalQuestions = questions.length;
1160+
let answeredCount = 0;
1161+
1162+
const $container = $('<div class="ai-msg ai-msg-question"></div>');
1163+
1164+
questions.forEach(function (q) {
1165+
const $qBlock = $('<div class="ai-question-block"></div>');
1166+
const $qText = $('<div class="ai-question-text"></div>');
1167+
$qText.text(q.question);
1168+
$qBlock.append($qText);
1169+
1170+
const $options = $('<div class="ai-question-options"></div>');
1171+
1172+
q.options.forEach(function (opt) {
1173+
const $opt = $('<button class="ai-question-option"></button>');
1174+
const $label = $('<span class="ai-question-option-label"></span>');
1175+
$label.text(opt.label);
1176+
$opt.append($label);
1177+
if (opt.description) {
1178+
const $desc = $('<span class="ai-question-option-desc"></span>');
1179+
$desc.text(opt.description);
1180+
$opt.append($desc);
1181+
}
1182+
1183+
$opt.on("click", function () {
1184+
if ($qBlock.data("answered")) {
1185+
return;
1186+
}
1187+
if (q.multiSelect) {
1188+
$opt.toggleClass("selected");
1189+
} else {
1190+
// Single select — answer immediately
1191+
$qBlock.data("answered", true);
1192+
$options.find(".ai-question-option").prop("disabled", true);
1193+
$opt.addClass("selected");
1194+
$qBlock.find(".ai-question-other").hide();
1195+
answers[q.question] = opt.label;
1196+
answeredCount++;
1197+
if (answeredCount >= totalQuestions) {
1198+
_sendQuestionAnswers(answers);
1199+
}
1200+
}
1201+
});
1202+
$options.append($opt);
1203+
});
1204+
1205+
// Multi-select submit button
1206+
if (q.multiSelect) {
1207+
const $submit = $('<button class="ai-question-submit">' +
1208+
'<i class="fa-solid fa-paper-plane"></i></button>');
1209+
$submit.on("click", function () {
1210+
if ($qBlock.data("answered")) {
1211+
return;
1212+
}
1213+
const selected = [];
1214+
$options.find(".ai-question-option.selected").each(function () {
1215+
selected.push($(this).find(".ai-question-option-label").text());
1216+
});
1217+
if (!selected.length) {
1218+
return;
1219+
}
1220+
$qBlock.data("answered", true);
1221+
$options.find(".ai-question-option").prop("disabled", true);
1222+
$submit.prop("disabled", true);
1223+
$qBlock.find(".ai-question-other").hide();
1224+
answers[q.question] = selected.join(", ");
1225+
answeredCount++;
1226+
if (answeredCount >= totalQuestions) {
1227+
_sendQuestionAnswers(answers);
1228+
}
1229+
});
1230+
$options.append($submit);
1231+
}
1232+
1233+
$qBlock.append($options);
1234+
1235+
// "Other" free-text input
1236+
const $other = $('<div class="ai-question-other"></div>');
1237+
const $input = $('<input type="text" class="ai-question-other-input" ' +
1238+
'placeholder="' + Strings.AI_CHAT_QUESTION_OTHER + '">');
1239+
const $sendOther = $('<button class="ai-question-other-submit">' +
1240+
'<i class="fa-solid fa-paper-plane"></i></button>');
1241+
function submitOther() {
1242+
const val = $input.val().trim();
1243+
if (!val || $qBlock.data("answered")) {
1244+
return;
1245+
}
1246+
$qBlock.data("answered", true);
1247+
$options.find(".ai-question-option").prop("disabled", true);
1248+
$input.prop("disabled", true);
1249+
$sendOther.prop("disabled", true);
1250+
answers[q.question] = val;
1251+
answeredCount++;
1252+
if (answeredCount >= totalQuestions) {
1253+
_sendQuestionAnswers(answers);
1254+
}
1255+
}
1256+
$sendOther.on("click", submitOther);
1257+
$input.on("keydown", function (e) {
1258+
if (e.key === "Enter") {
1259+
submitOther();
1260+
}
1261+
});
1262+
$other.append($input).append($sendOther);
1263+
$qBlock.append($other);
1264+
1265+
$container.append($qBlock);
1266+
});
1267+
1268+
$messages.append($container);
1269+
_scrollToBottom();
1270+
}
1271+
1272+
/**
1273+
* Send collected question answers to the node side.
1274+
*/
1275+
function _sendQuestionAnswers(answers) {
1276+
_nodeConnector.execPeer("answerQuestion", { answers: answers }).catch(function (err) {
1277+
console.warn("[AI UI] Failed to send question answer:", err.message);
1278+
});
1279+
}
1280+
11371281
// --- DOM helpers ---
11381282

11391283
function _appendUserMessage(text) {
@@ -1523,6 +1667,13 @@ define(function (require, exports, module) {
15231667
summary: StringUtils.format(Strings.AI_CHAT_TOOL_WAITING, input.seconds || "?"),
15241668
lines: []
15251669
};
1670+
case "AskUserQuestion": {
1671+
const qs = input.questions || [];
1672+
return {
1673+
summary: Strings.AI_CHAT_TOOL_QUESTION,
1674+
lines: qs.map(function (q) { return q.question; })
1675+
};
1676+
}
15261677
case "TodoWrite": {
15271678
const todos = input.todos || [];
15281679
const completed = todos.filter(function (t) { return t.status === "completed"; }).length;

src/nls/root/strings.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1878,6 +1878,8 @@ define({
18781878
"AI_CHAT_TOOL_WAITED": "Done waiting {0}s",
18791879
"AI_CHAT_COPY_CODE": "Copy",
18801880
"AI_CHAT_COPIED_CODE": "Copied!",
1881+
"AI_CHAT_TOOL_QUESTION": "Question",
1882+
"AI_CHAT_QUESTION_OTHER": "Type a custom answer\u2026",
18811883

18821884
// demo start - Phoenix Code Playground - Interactive Onboarding
18831885
"DEMO_SECTION1_TITLE": "Edit in Live Preview",

0 commit comments

Comments
 (0)