feat(tool): decouple AskUserQuestion into standalone question channel
Browse filesAdd a dedicated question service and overlay instead of routing
AskUserQuestion through the permission system. Wire tab/arrow keybindings
in the standalone prompt and clarify the tool prompt with an explicit
parameter-structure example to reduce first-try schema errors.
src/components/question/QuestionPrompt.tsx
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { c as _c } from "react/compiler-runtime";
|
| 2 |
+
import React, { useCallback, useState } from "react";
|
| 3 |
+
import type { Question } from "../../tools/AskUserQuestionTool/AskUserQuestionTool.js";
|
| 4 |
+
import { questionService, type QuestionRequest } from "../../services/question/questionService.js";
|
| 5 |
+
import { useMultipleChoiceState } from "../permissions/AskUserQuestionPermissionRequest/use-multiple-choice-state.js";
|
| 6 |
+
import { QuestionView } from "../permissions/AskUserQuestionPermissionRequest/QuestionView.js";
|
| 7 |
+
import { SubmitQuestionsView } from "../permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.js";
|
| 8 |
+
import { useRegisterOverlay } from "../../context/overlayContext.js";
|
| 9 |
+
import { useKeybindings } from "../../keybindings/useKeybinding.js";
|
| 10 |
+
import type { PermissionDecision } from "../../utils/permissions/PermissionResult.js";
|
| 11 |
+
|
| 12 |
+
const NO_PERMISSION: PermissionDecision = { behavior: "allow" } as PermissionDecision;
|
| 13 |
+
|
| 14 |
+
type Props = {
|
| 15 |
+
request: QuestionRequest;
|
| 16 |
+
onResolved: () => void;
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
/**
|
| 20 |
+
* Standalone question overlay (decoupled from the permission system).
|
| 21 |
+
* Renders the same choice UI as AskUserQuestion but resolves the
|
| 22 |
+
* questionService pending request on reply/reject.
|
| 23 |
+
*/
|
| 24 |
+
export function QuestionPrompt(props: Props) {
|
| 25 |
+
const $ = _c(40);
|
| 26 |
+
const { request, onResolved } = props;
|
| 27 |
+
const questions = request.questions as unknown as Question[];
|
| 28 |
+
const [answers, setAnswers] = useState<Record<string, string>>({});
|
| 29 |
+
const state = useMultipleChoiceState();
|
| 30 |
+
const {
|
| 31 |
+
currentQuestionIndex,
|
| 32 |
+
questionStates,
|
| 33 |
+
isInTextInput,
|
| 34 |
+
nextQuestion,
|
| 35 |
+
prevQuestion,
|
| 36 |
+
updateQuestionState,
|
| 37 |
+
setAnswer,
|
| 38 |
+
setTextInputMode,
|
| 39 |
+
} = state;
|
| 40 |
+
const currentQuestion =
|
| 41 |
+
currentQuestionIndex < questions.length ? questions[currentQuestionIndex] : null;
|
| 42 |
+
const isInSubmitView = currentQuestionIndex === questions.length;
|
| 43 |
+
const allQuestionsAnswered = questions.every(q => q?.question && !!answers[q.question]);
|
| 44 |
+
const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect;
|
| 45 |
+
const maxIndex = hideSubmitTab ? questions.length - 1 : questions.length;
|
| 46 |
+
useRegisterOverlay("question", true);
|
| 47 |
+
let t0;
|
| 48 |
+
if ($[0] !== answers || $[1] !== questions) {
|
| 49 |
+
t0 = questions.every(q_0 => q_0?.question && !!answers[q_0.question]) ?? false;
|
| 50 |
+
$[0] = answers;
|
| 51 |
+
$[1] = questions;
|
| 52 |
+
$[2] = t0;
|
| 53 |
+
} else {
|
| 54 |
+
t0 = $[2];
|
| 55 |
+
}
|
| 56 |
+
let t1;
|
| 57 |
+
if ($[3] !== answers) {
|
| 58 |
+
t1 = (q_1: string, a: string | string[], textInput?: string, shouldAdvance?: boolean) => {
|
| 59 |
+
const isMulti = Array.isArray(a);
|
| 60 |
+
const value = isMulti ? (a as string[]).join(", ") : (a as string);
|
| 61 |
+
const next = { ...answers, [q_1]: value };
|
| 62 |
+
setAnswers(next);
|
| 63 |
+
setAnswer(q_1, value, shouldAdvance);
|
| 64 |
+
if (!isMulti && shouldAdvance !== false && questions.length === 1) {
|
| 65 |
+
void reply([value]);
|
| 66 |
+
}
|
| 67 |
+
};
|
| 68 |
+
$[3] = answers;
|
| 69 |
+
$[4] = t1;
|
| 70 |
+
} else {
|
| 71 |
+
t1 = $[4];
|
| 72 |
+
}
|
| 73 |
+
const handleAnswer = t1;
|
| 74 |
+
let t2;
|
| 75 |
+
if ($[5] === Symbol.for("react.memo_cache_sentinel")) {
|
| 76 |
+
t2 = (q_2: string) => {
|
| 77 |
+
const next = { ...answers };
|
| 78 |
+
delete next[q_2];
|
| 79 |
+
setAnswers(next);
|
| 80 |
+
};
|
| 81 |
+
$[5] = t2;
|
| 82 |
+
} else {
|
| 83 |
+
t2 = $[5];
|
| 84 |
+
}
|
| 85 |
+
const handleCancel = t2;
|
| 86 |
+
let t3;
|
| 87 |
+
if ($[6] !== answers || $[7] !== questions) {
|
| 88 |
+
t3 = (final: "submit" | "cancel") => {
|
| 89 |
+
if (final === "cancel") {
|
| 90 |
+
handleCancelReply();
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
const result = questions.map(q_3 => {
|
| 94 |
+
const a = answers[q_3.question];
|
| 95 |
+
return a ? (q_3.multiSelect ? a.split(", ") : [a]) : [];
|
| 96 |
+
});
|
| 97 |
+
void reply(result);
|
| 98 |
+
};
|
| 99 |
+
$[6] = answers;
|
| 100 |
+
$[7] = questions;
|
| 101 |
+
$[8] = t3;
|
| 102 |
+
} else {
|
| 103 |
+
t3 = $[8];
|
| 104 |
+
}
|
| 105 |
+
const handleFinal = t3;
|
| 106 |
+
let t4;
|
| 107 |
+
if ($[9] === Symbol.for("react.memo_cache_sentinel")) {
|
| 108 |
+
t4 = () => {
|
| 109 |
+
questionService.reject(request.id);
|
| 110 |
+
onResolved();
|
| 111 |
+
};
|
| 112 |
+
$[9] = t4;
|
| 113 |
+
} else {
|
| 114 |
+
t4 = $[9];
|
| 115 |
+
}
|
| 116 |
+
const handleCancelReply = t4;
|
| 117 |
+
let t5;
|
| 118 |
+
if ($[10] !== answers || $[11] !== questions) {
|
| 119 |
+
t5 = async (result: string[][]) => {
|
| 120 |
+
questionService.reply({ requestID: request.id, answers: result });
|
| 121 |
+
onResolved();
|
| 122 |
+
};
|
| 123 |
+
$[10] = answers;
|
| 124 |
+
$[11] = questions;
|
| 125 |
+
$[12] = t5;
|
| 126 |
+
} else {
|
| 127 |
+
t5 = $[12];
|
| 128 |
+
}
|
| 129 |
+
const reply = t5;
|
| 130 |
+
let t6;
|
| 131 |
+
if ($[13] !== currentQuestionIndex || $[14] !== maxIndex || $[15] !== nextQuestion) {
|
| 132 |
+
t6 = () => {
|
| 133 |
+
if (currentQuestionIndex < maxIndex) nextQuestion();
|
| 134 |
+
};
|
| 135 |
+
$[13] = currentQuestionIndex;
|
| 136 |
+
$[14] = maxIndex;
|
| 137 |
+
$[15] = nextQuestion;
|
| 138 |
+
$[16] = t6;
|
| 139 |
+
} else {
|
| 140 |
+
t6 = $[16];
|
| 141 |
+
}
|
| 142 |
+
const handleTabNext = t6;
|
| 143 |
+
let t7;
|
| 144 |
+
if ($[17] !== currentQuestionIndex || $[18] !== prevQuestion) {
|
| 145 |
+
t7 = () => {
|
| 146 |
+
if (currentQuestionIndex > 0) prevQuestion();
|
| 147 |
+
};
|
| 148 |
+
$[17] = currentQuestionIndex;
|
| 149 |
+
$[18] = prevQuestion;
|
| 150 |
+
$[19] = t7;
|
| 151 |
+
} else {
|
| 152 |
+
t7 = $[19];
|
| 153 |
+
}
|
| 154 |
+
const handleTabPrev = t7;
|
| 155 |
+
useKeybindings(
|
| 156 |
+
{ "tabs:previous": handleTabPrev, "tabs:next": handleTabNext },
|
| 157 |
+
{ context: "Tabs", isActive: !(isInTextInput && !isInSubmitView) },
|
| 158 |
+
);
|
| 159 |
+
if (currentQuestion) {
|
| 160 |
+
let t8;
|
| 161 |
+
if (
|
| 162 |
+
$[20] !== answers ||
|
| 163 |
+
$[21] !== currentQuestion ||
|
| 164 |
+
$[22] !== currentQuestionIndex ||
|
| 165 |
+
$[23] !== handleAnswer ||
|
| 166 |
+
$[24] !== handleCancel ||
|
| 167 |
+
$[25] !== handleTabNext ||
|
| 168 |
+
$[26] !== handleTabPrev ||
|
| 169 |
+
$[27] !== hideSubmitTab ||
|
| 170 |
+
$[28] !== nextQuestion ||
|
| 171 |
+
$[29] !== questions ||
|
| 172 |
+
$[30] !== questionStates ||
|
| 173 |
+
$[31] !== setTextInputMode ||
|
| 174 |
+
$[32] !== updateQuestionState
|
| 175 |
+
) {
|
| 176 |
+
t8 = (
|
| 177 |
+
<QuestionView
|
| 178 |
+
question={currentQuestion}
|
| 179 |
+
questions={questions}
|
| 180 |
+
currentQuestionIndex={currentQuestionIndex}
|
| 181 |
+
answers={answers}
|
| 182 |
+
questionStates={questionStates}
|
| 183 |
+
hideSubmitTab={hideSubmitTab}
|
| 184 |
+
onUpdateQuestionState={updateQuestionState}
|
| 185 |
+
onAnswer={handleAnswer}
|
| 186 |
+
onTextInputFocus={setTextInputMode}
|
| 187 |
+
onCancel={handleCancelReply}
|
| 188 |
+
onSubmit={nextQuestion}
|
| 189 |
+
onTabPrev={handleTabPrev}
|
| 190 |
+
onTabNext={handleTabNext}
|
| 191 |
+
onRespondToClaude={handleCancelReply}
|
| 192 |
+
onFinishPlanInterview={handleCancelReply}
|
| 193 |
+
/>
|
| 194 |
+
);
|
| 195 |
+
$[20] = answers;
|
| 196 |
+
$[21] = currentQuestion;
|
| 197 |
+
$[22] = currentQuestionIndex;
|
| 198 |
+
$[23] = handleAnswer;
|
| 199 |
+
$[24] = handleCancel;
|
| 200 |
+
$[25] = handleTabNext;
|
| 201 |
+
$[26] = handleTabPrev;
|
| 202 |
+
$[27] = hideSubmitTab;
|
| 203 |
+
$[28] = nextQuestion;
|
| 204 |
+
$[29] = questions;
|
| 205 |
+
$[30] = questionStates;
|
| 206 |
+
$[31] = setTextInputMode;
|
| 207 |
+
$[32] = updateQuestionState;
|
| 208 |
+
$[33] = t8;
|
| 209 |
+
} else {
|
| 210 |
+
t8 = $[33];
|
| 211 |
+
}
|
| 212 |
+
return t8;
|
| 213 |
+
}
|
| 214 |
+
if (isInSubmitView) {
|
| 215 |
+
let t9;
|
| 216 |
+
if (
|
| 217 |
+
$[34] !== allQuestionsAnswered ||
|
| 218 |
+
$[35] !== answers ||
|
| 219 |
+
$[36] !== currentQuestionIndex ||
|
| 220 |
+
$[37] !== questions ||
|
| 221 |
+
$[38] !== handleFinal
|
| 222 |
+
) {
|
| 223 |
+
t9 = (
|
| 224 |
+
<SubmitQuestionsView
|
| 225 |
+
questions={questions}
|
| 226 |
+
currentQuestionIndex={currentQuestionIndex}
|
| 227 |
+
answers={answers}
|
| 228 |
+
allQuestionsAnswered={allQuestionsAnswered}
|
| 229 |
+
permissionResult={NO_PERMISSION}
|
| 230 |
+
onFinalResponse={handleFinal}
|
| 231 |
+
/>
|
| 232 |
+
);
|
| 233 |
+
$[34] = allQuestionsAnswered;
|
| 234 |
+
$[35] = answers;
|
| 235 |
+
$[36] = currentQuestionIndex;
|
| 236 |
+
$[37] = questions;
|
| 237 |
+
$[38] = handleFinal;
|
| 238 |
+
$[39] = t9;
|
| 239 |
+
} else {
|
| 240 |
+
t9 = $[39];
|
| 241 |
+
}
|
| 242 |
+
return t9;
|
| 243 |
+
}
|
| 244 |
+
return null;
|
| 245 |
+
}
|
src/screens/REPL.tsx
CHANGED
|
@@ -1556,6 +1556,19 @@ export function REPL({
|
|
| 1556 |
reject: (error: Error) => void
|
| 1557 |
}>
|
| 1558 |
>([])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1559 |
|
| 1560 |
// Track bridge cleanup functions for sandbox permission requests so the
|
| 1561 |
// local dialog handler can cancel the remote prompt when the local user
|
|
@@ -2672,6 +2685,7 @@ export function REPL({
|
|
| 2672 |
| 'sandbox-permission'
|
| 2673 |
| 'tool-permission'
|
| 2674 |
| 'prompt'
|
|
|
|
| 2675 |
| 'worker-sandbox-permission'
|
| 2676 |
| 'elicitation'
|
| 2677 |
| 'cost'
|
|
@@ -2706,6 +2720,7 @@ export function REPL({
|
|
| 2706 |
if (allowDialogsWithAnimation && toolUseConfirmQueue[0])
|
| 2707 |
return 'tool-permission'
|
| 2708 |
if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'
|
|
|
|
| 2709 |
// Worker sandbox permission prompts (network access) from swarm workers
|
| 2710 |
if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0])
|
| 2711 |
return 'worker-sandbox-permission'
|
|
@@ -2789,7 +2804,8 @@ export function REPL({
|
|
| 2789 |
useEffect(() => {
|
| 2790 |
if (!isLoading) return
|
| 2791 |
|
| 2792 |
-
const isPaused =
|
|
|
|
| 2793 |
const now = Date.now()
|
| 2794 |
|
| 2795 |
if (isPaused && pauseStartTimeRef.current === null) {
|
|
@@ -2813,8 +2829,12 @@ export function REPL({
|
|
| 2813 |
// no 1-frame flash of the wrong scroll position.
|
| 2814 |
const prevDialogRef = useRef(focusedInputDialog)
|
| 2815 |
useLayoutEffect(() => {
|
| 2816 |
-
const was =
|
| 2817 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2818 |
if (was !== now) repinScroll()
|
| 2819 |
prevDialogRef.current = focusedInputDialog
|
| 2820 |
}, [focusedInputDialog, repinScroll])
|
|
@@ -6062,6 +6082,19 @@ export function REPL({
|
|
| 6062 |
/>
|
| 6063 |
) : null
|
| 6064 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6065 |
// Narrow terminals: companion collapses to a one-liner that REPL stacks
|
| 6066 |
// on its own row (above input in fullscreen, below in scrollback) instead
|
| 6067 |
// of row-beside. Wide terminals keep the row layout with sprite on the right.
|
|
@@ -6150,7 +6183,7 @@ export function REPL({
|
|
| 6150 |
>
|
| 6151 |
<FullscreenLayout
|
| 6152 |
scrollRef={scrollRef}
|
| 6153 |
-
overlay={toolPermissionOverlay}
|
| 6154 |
bottomFloat={
|
| 6155 |
feature('BUDDY') && companionVisible && !companionNarrow ? (
|
| 6156 |
<CompanionFloatingBubble />
|
|
|
|
| 1556 |
reject: (error: Error) => void
|
| 1557 |
}>
|
| 1558 |
>([])
|
| 1559 |
+
const [questionRequest, setQuestionRequest] =
|
| 1560 |
+
useState<import('../services/question/questionService.js').QuestionRequest | null>(null)
|
| 1561 |
+
useEffect(() => {
|
| 1562 |
+
const { questionService } = require('../services/question/questionService.js') as typeof import('../services/question/questionService.js')
|
| 1563 |
+
const onAsked = (request: import('../services/question/questionService.js').QuestionRequest) => {
|
| 1564 |
+
setQuestionRequest(request)
|
| 1565 |
+
}
|
| 1566 |
+
questionService.events.on('asked', onAsked)
|
| 1567 |
+
return () => {
|
| 1568 |
+
questionService.events.off('asked', onAsked)
|
| 1569 |
+
}
|
| 1570 |
+
}, [])
|
| 1571 |
+
|
| 1572 |
|
| 1573 |
// Track bridge cleanup functions for sandbox permission requests so the
|
| 1574 |
// local dialog handler can cancel the remote prompt when the local user
|
|
|
|
| 2685 |
| 'sandbox-permission'
|
| 2686 |
| 'tool-permission'
|
| 2687 |
| 'prompt'
|
| 2688 |
+
| 'question'
|
| 2689 |
| 'worker-sandbox-permission'
|
| 2690 |
| 'elicitation'
|
| 2691 |
| 'cost'
|
|
|
|
| 2720 |
if (allowDialogsWithAnimation && toolUseConfirmQueue[0])
|
| 2721 |
return 'tool-permission'
|
| 2722 |
if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'
|
| 2723 |
+
if (allowDialogsWithAnimation && questionRequest) return 'question'
|
| 2724 |
// Worker sandbox permission prompts (network access) from swarm workers
|
| 2725 |
if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0])
|
| 2726 |
return 'worker-sandbox-permission'
|
|
|
|
| 2804 |
useEffect(() => {
|
| 2805 |
if (!isLoading) return
|
| 2806 |
|
| 2807 |
+
const isPaused =
|
| 2808 |
+
focusedInputDialog === 'tool-permission' || focusedInputDialog === 'question'
|
| 2809 |
const now = Date.now()
|
| 2810 |
|
| 2811 |
if (isPaused && pauseStartTimeRef.current === null) {
|
|
|
|
| 2829 |
// no 1-frame flash of the wrong scroll position.
|
| 2830 |
const prevDialogRef = useRef(focusedInputDialog)
|
| 2831 |
useLayoutEffect(() => {
|
| 2832 |
+
const was =
|
| 2833 |
+
prevDialogRef.current === 'tool-permission' ||
|
| 2834 |
+
prevDialogRef.current === 'question'
|
| 2835 |
+
const now =
|
| 2836 |
+
focusedInputDialog === 'tool-permission' ||
|
| 2837 |
+
focusedInputDialog === 'question'
|
| 2838 |
if (was !== now) repinScroll()
|
| 2839 |
prevDialogRef.current = focusedInputDialog
|
| 2840 |
}, [focusedInputDialog, repinScroll])
|
|
|
|
| 6082 |
/>
|
| 6083 |
) : null
|
| 6084 |
|
| 6085 |
+
// Standalone question overlay (decoupled from the permission system).
|
| 6086 |
+
// Reuses the AskUserQuestion choice UI but resolves questionService.
|
| 6087 |
+
const QuestionPromptBox = require('../components/question/QuestionPrompt.js').QuestionPrompt
|
| 6088 |
+
|
| 6089 |
+
const questionOverlay =
|
| 6090 |
+
focusedInputDialog === 'question' && questionRequest ? (
|
| 6091 |
+
<QuestionPromptBox
|
| 6092 |
+
key={questionRequest.id}
|
| 6093 |
+
request={questionRequest}
|
| 6094 |
+
onResolved={() => setQuestionRequest(null)}
|
| 6095 |
+
/>
|
| 6096 |
+
) : null
|
| 6097 |
+
|
| 6098 |
// Narrow terminals: companion collapses to a one-liner that REPL stacks
|
| 6099 |
// on its own row (above input in fullscreen, below in scrollback) instead
|
| 6100 |
// of row-beside. Wide terminals keep the row layout with sprite on the right.
|
|
|
|
| 6183 |
>
|
| 6184 |
<FullscreenLayout
|
| 6185 |
scrollRef={scrollRef}
|
| 6186 |
+
overlay={toolPermissionOverlay || questionOverlay}
|
| 6187 |
bottomFloat={
|
| 6188 |
feature('BUDDY') && companionVisible && !companionNarrow ? (
|
| 6189 |
<CompanionFloatingBubble />
|
src/services/question/questionService.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { EventEmitter } from '../../ink/events/emitter.js'
|
| 2 |
+
|
| 3 |
+
export interface QuestionOption {
|
| 4 |
+
label: string
|
| 5 |
+
description?: string
|
| 6 |
+
preview?: string
|
| 7 |
+
}
|
| 8 |
+
|
| 9 |
+
export interface QuestionInfo {
|
| 10 |
+
question: string
|
| 11 |
+
header?: string
|
| 12 |
+
options: QuestionOption[]
|
| 13 |
+
multiSelect?: boolean
|
| 14 |
+
custom?: boolean
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export interface QuestionRequest {
|
| 18 |
+
id: string
|
| 19 |
+
questions: QuestionInfo[]
|
| 20 |
+
tool?: {
|
| 21 |
+
messageID: string
|
| 22 |
+
callID: string
|
| 23 |
+
}
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export type QuestionAnswer = string[]
|
| 27 |
+
|
| 28 |
+
export type ReplyInput = {
|
| 29 |
+
requestID: string
|
| 30 |
+
answers: QuestionAnswer[]
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
interface Pending {
|
| 34 |
+
readonly request: QuestionRequest
|
| 35 |
+
resolve: (answers: ReadonlyArray<QuestionAnswer>) => void
|
| 36 |
+
reject: (reason?: Error) => void
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
class RejectedError extends Error {
|
| 40 |
+
constructor() {
|
| 41 |
+
super('The user dismissed this question')
|
| 42 |
+
this.name = 'QuestionV2.RejectedError'
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/**
|
| 47 |
+
* Standalone question channel, decoupled from the permission system.
|
| 48 |
+
* Mirrors opencode's QuestionV2 service: a tool's `call` awaits `ask`,
|
| 49 |
+
* the TUI renders an independent overlay, and `reply`/`reject` resolve it.
|
| 50 |
+
*/
|
| 51 |
+
class QuestionService {
|
| 52 |
+
private pending = new Map<string, Pending>()
|
| 53 |
+
private seq = 0
|
| 54 |
+
readonly events = new EventEmitter()
|
| 55 |
+
|
| 56 |
+
private nextID(): string {
|
| 57 |
+
this.seq += 1
|
| 58 |
+
return `que_${Date.now().toString(36)}_${this.seq}`
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
ask(questions: QuestionInfo[]): Promise<ReadonlyArray<QuestionAnswer>> {
|
| 62 |
+
const id = this.nextID()
|
| 63 |
+
const request: QuestionRequest = { id, questions }
|
| 64 |
+
return new Promise<ReadonlyArray<QuestionAnswer>>((resolve, reject) => {
|
| 65 |
+
this.pending.set(id, { request, resolve, reject })
|
| 66 |
+
this.events.emit('asked', request)
|
| 67 |
+
})
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
reply(input: ReplyInput): boolean {
|
| 71 |
+
const existing = this.pending.get(input.requestID)
|
| 72 |
+
if (!existing) return false
|
| 73 |
+
this.events.emit('replied', {
|
| 74 |
+
requestID: existing.request.id,
|
| 75 |
+
answers: input.answers.map(a => [...a]),
|
| 76 |
+
})
|
| 77 |
+
existing.resolve(input.answers)
|
| 78 |
+
this.pending.delete(input.requestID)
|
| 79 |
+
return true
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
reject(requestID: string): boolean {
|
| 83 |
+
const existing = this.pending.get(requestID)
|
| 84 |
+
if (!existing) return false
|
| 85 |
+
this.events.emit('rejected', { requestID: existing.request.id })
|
| 86 |
+
existing.reject(new RejectedError())
|
| 87 |
+
this.pending.delete(requestID)
|
| 88 |
+
return true
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
list(): QuestionRequest[] {
|
| 92 |
+
return Array.from(this.pending.values(), p => p.request)
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
current(): QuestionRequest | undefined {
|
| 96 |
+
const all = this.list()
|
| 97 |
+
return all[all.length - 1]
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
export const questionService = new QuestionService()
|
| 102 |
+
export { RejectedError }
|
src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx
CHANGED
|
@@ -10,6 +10,7 @@ import { Box, Text } from '../../ink.js';
|
|
| 10 |
import type { Tool } from '../../Tool.js';
|
| 11 |
import { buildTool, type ToolDef } from '../../Tool.js';
|
| 12 |
import { lazySchema } from '../../utils/lazySchema.js';
|
|
|
|
| 13 |
import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js';
|
| 14 |
const questionOptionSchema = lazySchema(() => z.object({
|
| 15 |
label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'),
|
|
@@ -181,8 +182,7 @@ export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
| 181 |
},
|
| 182 |
async checkPermissions(input) {
|
| 183 |
return {
|
| 184 |
-
behavior: '
|
| 185 |
-
message: 'Answer questions?',
|
| 186 |
updatedInput: input
|
| 187 |
};
|
| 188 |
},
|
|
@@ -208,9 +208,27 @@ export const AskUserQuestionTool: Tool<InputSchema, Output> = buildTool({
|
|
| 208 |
},
|
| 209 |
async call({
|
| 210 |
questions,
|
| 211 |
-
answers = {},
|
| 212 |
annotations
|
| 213 |
}, _context) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
return {
|
| 215 |
data: {
|
| 216 |
questions,
|
|
|
|
| 10 |
import type { Tool } from '../../Tool.js';
|
| 11 |
import { buildTool, type ToolDef } from '../../Tool.js';
|
| 12 |
import { lazySchema } from '../../utils/lazySchema.js';
|
| 13 |
+
import { questionService, type QuestionInfo } from '../../services/question/questionService.js';
|
| 14 |
import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT } from './prompt.js';
|
| 15 |
const questionOptionSchema = lazySchema(() => z.object({
|
| 16 |
label: z.string().describe('The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.'),
|
|
|
|
| 182 |
},
|
| 183 |
async checkPermissions(input) {
|
| 184 |
return {
|
| 185 |
+
behavior: 'allow' as const,
|
|
|
|
| 186 |
updatedInput: input
|
| 187 |
};
|
| 188 |
},
|
|
|
|
| 208 |
},
|
| 209 |
async call({
|
| 210 |
questions,
|
|
|
|
| 211 |
annotations
|
| 212 |
}, _context) {
|
| 213 |
+
const asked: QuestionInfo[] = questions.map(q => ({
|
| 214 |
+
question: q.question,
|
| 215 |
+
header: q.header,
|
| 216 |
+
options: q.options.map(o => ({
|
| 217 |
+
label: o.label,
|
| 218 |
+
description: o.description,
|
| 219 |
+
preview: o.preview
|
| 220 |
+
})),
|
| 221 |
+
multiSelect: q.multiSelect,
|
| 222 |
+
custom: true
|
| 223 |
+
}))
|
| 224 |
+
const result = await questionService.ask(asked)
|
| 225 |
+
const answers: Record<string, string> = {}
|
| 226 |
+
questions.forEach((q, i) => {
|
| 227 |
+
const answer = result[i]
|
| 228 |
+
if (answer && answer.length > 0) {
|
| 229 |
+
answers[q.question] = q.multiSelect ? answer.join(', ') : answer[0]
|
| 230 |
+
}
|
| 231 |
+
})
|
| 232 |
return {
|
| 233 |
data: {
|
| 234 |
questions,
|
src/tools/AskUserQuestionTool/prompt.ts
CHANGED
|
@@ -39,6 +39,36 @@ Usage notes:
|
|
| 39 |
- Users will always be able to select "Other" to provide custom text input
|
| 40 |
- Use multiSelect: true to allow multiple answers to be selected for a question
|
| 41 |
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ${EXIT_PLAN_MODE_TOOL_NAME} for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g., "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ${EXIT_PLAN_MODE_TOOL_NAME}. If you need plan approval, use ${EXIT_PLAN_MODE_TOOL_NAME} instead.
|
| 44 |
`
|
|
|
|
| 39 |
- Users will always be able to select "Other" to provide custom text input
|
| 40 |
- Use multiSelect: true to allow multiple answers to be selected for a question
|
| 41 |
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
|
| 42 |
+
- Do NOT add an "Other" option yourself; it is provided automatically.
|
| 43 |
+
|
| 44 |
+
Parameter structure (IMPORTANT):
|
| 45 |
+
- \`questions\` is an array of 1-4 question objects.
|
| 46 |
+
- Each option MUST be an object with a \`label\` (short text) and a \`description\` (what it means). Never pass options as plain strings.
|
| 47 |
+
- Do not add any top-level keys other than \`questions\`.
|
| 48 |
+
|
| 49 |
+
Example (ask two questions at once):
|
| 50 |
+
\`\`\`json
|
| 51 |
+
{
|
| 52 |
+
"questions": [
|
| 53 |
+
{
|
| 54 |
+
"question": "How should image rendering be implemented?",
|
| 55 |
+
"header": "Image",
|
| 56 |
+
"options": [
|
| 57 |
+
{ "label": "Terminal inline", "description": "Render images inline in the terminal" },
|
| 58 |
+
{ "label": "External viewer", "description": "Open images in an external previewer" }
|
| 59 |
+
]
|
| 60 |
+
},
|
| 61 |
+
{
|
| 62 |
+
"question": "How should logs be stored?",
|
| 63 |
+
"header": "Logging",
|
| 64 |
+
"options": [
|
| 65 |
+
{ "label": "File", "description": "Write logs to a file" },
|
| 66 |
+
{ "label": "Database", "description": "Write logs to a database" }
|
| 67 |
+
]
|
| 68 |
+
}
|
| 69 |
+
]
|
| 70 |
+
}
|
| 71 |
+
\`\`\`
|
| 72 |
|
| 73 |
Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is my plan ready?" or "Should I proceed?" - use ${EXIT_PLAN_MODE_TOOL_NAME} for plan approval. IMPORTANT: Do not reference "the plan" in your questions (e.g., "Do you have feedback about the plan?", "Does the plan look good?") because the user cannot see the plan in the UI until you call ${EXIT_PLAN_MODE_TOOL_NAME}. If you need plan approval, use ${EXIT_PLAN_MODE_TOOL_NAME} instead.
|
| 74 |
`
|