Skip to content

Commit e5aa5d1

Browse files
committed
feat(tui): handle OpenCode questions
Made-with: Cursor
1 parent 954e49f commit e5aa5d1

7 files changed

Lines changed: 444 additions & 6 deletions

File tree

‎apps/tui/src/components/chat.tsx‎

Lines changed: 221 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ScrollBoxRenderable } from "@opentui/core";
22
import { TextAttributes } from "@opentui/core";
33
import { useKeyboard } from "@opentui/react";
4-
import type { DaemonJob } from "@techatnyu/ralphd";
4+
import type { DaemonJob, QuestionInfo } from "@techatnyu/ralphd";
55
import { daemon } from "@techatnyu/ralphd";
66
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
77
import { createPromptTask } from "../lib/prompt-task";
@@ -15,11 +15,48 @@ interface ChatMessage {
1515
content: string;
1616
}
1717

18+
interface PendingQuestion {
19+
requestId: string;
20+
questions: QuestionInfo[];
21+
currentIndex: number;
22+
selectedIndex: number;
23+
answers: string[][];
24+
}
25+
1826
let messageIdCounter = 0;
1927
function msg(role: Role, content: string): ChatMessage {
2028
return { id: ++messageIdCounter, role, content };
2129
}
2230

31+
function answerSummary(questions: QuestionInfo[], answers: string[][]): string {
32+
return questions
33+
.map((question, index) => {
34+
const labels = answers[index] ?? [];
35+
return `${question.header}: ${labels.join(", ") || "(no answer)"}`;
36+
})
37+
.join("\n");
38+
}
39+
40+
function initialQuestionDialog(
41+
requestId: string,
42+
questions: QuestionInfo[],
43+
): PendingQuestion {
44+
return {
45+
requestId,
46+
questions,
47+
currentIndex: 0,
48+
selectedIndex: 0,
49+
answers: questions.map(() => []),
50+
};
51+
}
52+
53+
function clampOptionIndex(index: number, question: QuestionInfo): number {
54+
if (question.options.length === 0) {
55+
return 0;
56+
}
57+
return Math.min(Math.max(index, 0), question.options.length - 1);
58+
}
59+
2360
/** Convert a terminal job into user + assistant/system message pair. */
2461
function messagesFromJob(job: DaemonJob): ChatMessage[] {
2562
const prompt =
@@ -62,14 +99,17 @@ export function Chat({
6299
const [errorMessage, setErrorMessage] = useState<string | null>(null);
63100
const [sessionId, setSessionId] = useState<string | null>(initialSessionId);
64101
const [hydrated, setHydrated] = useState(false);
102+
const [pendingQuestion, setPendingQuestion] =
103+
useState<PendingQuestion | null>(null);
65104
const sendLockRef = useRef(false);
66105
const chatScrollRef = useRef<ScrollBoxRenderable | null>(null);
67106

68107
const placeholder = useMemo(() => {
69108
if (!hydrated) return "Loading history...";
109+
if (pendingQuestion) return "Answer OpenCode's question and press Enter";
70110
if (isLoading) return "Waiting for response...";
71111
return "Type a message and press Enter";
72-
}, [isLoading, hydrated]);
112+
}, [isLoading, hydrated, pendingQuestion]);
73113

74114
// Shared logic for consuming a job stream into a placeholder message.
75115
const consumeStream = useCallback(
@@ -89,6 +129,10 @@ export function Chat({
89129
: m,
90130
),
91131
);
132+
} else if (event.type === "question") {
133+
setPendingQuestion(
134+
initialQuestionDialog(event.requestId, event.questions),
135+
);
92136
} else if (event.type === "done") {
93137
if (event.job.sessionId && !sessionId) {
94138
setSessionId(event.job.sessionId);
@@ -234,11 +278,125 @@ export function Chat({
234278
};
235279
}, [instanceId, instanceName, sessionId, consumeStream]);
236280

281+
const submitQuestionAnswers = useCallback(
282+
async (question: PendingQuestion, answers: string[][]) => {
283+
sendLockRef.current = true;
284+
setErrorMessage(null);
285+
setPendingQuestion(null);
286+
try {
287+
await daemon.replyToQuestion({
288+
instanceId,
289+
requestId: question.requestId,
290+
answers,
291+
});
292+
setMessages((prev) => [
293+
...prev,
294+
msg("user", answerSummary(question.questions, answers)),
295+
]);
296+
} catch (error) {
297+
const message =
298+
error instanceof Error
299+
? error.message
300+
: "Unknown error while answering question.";
301+
setErrorMessage(message);
302+
setPendingQuestion(question);
303+
setMessages((prev) => [...prev, msg("system", `Error: ${message}`)]);
304+
} finally {
305+
sendLockRef.current = false;
306+
}
307+
},
308+
[instanceId],
309+
);
310+
237311
useKeyboard((event) => {
238312
if (event.ctrl && event.name === "c") {
239313
onQuit();
240314
}
241315

316+
if (pendingQuestion) {
317+
const currentQuestion =
318+
pendingQuestion.questions[pendingQuestion.currentIndex];
319+
if (!currentQuestion) {
320+
return;
321+
}
322+
323+
if (event.name === "up" || event.name === "k") {
324+
setPendingQuestion((prev) =>
325+
prev
326+
? {
327+
...prev,
328+
selectedIndex: clampOptionIndex(
329+
prev.selectedIndex - 1,
330+
currentQuestion,
331+
),
332+
}
333+
: prev,
334+
);
335+
return;
336+
}
337+
338+
if (event.name === "down" || event.name === "j") {
339+
setPendingQuestion((prev) =>
340+
prev
341+
? {
342+
...prev,
343+
selectedIndex: clampOptionIndex(
344+
prev.selectedIndex + 1,
345+
currentQuestion,
346+
),
347+
}
348+
: prev,
349+
);
350+
return;
351+
}
352+
353+
if (event.name === "space" && currentQuestion.multiple) {
354+
const option = currentQuestion.options[pendingQuestion.selectedIndex];
355+
if (!option) {
356+
return;
357+
}
358+
setPendingQuestion((prev) => {
359+
if (!prev) return prev;
360+
const answers = prev.answers.map((answer) => [...answer]);
361+
const selected = answers[prev.currentIndex] ?? [];
362+
answers[prev.currentIndex] = selected.includes(option.label)
363+
? selected.filter((label) => label !== option.label)
364+
: [...selected, option.label];
365+
return { ...prev, answers };
366+
});
367+
return;
368+
}
369+
370+
if (event.name === "return") {
371+
const option = currentQuestion.options[pendingQuestion.selectedIndex];
372+
const answers = pendingQuestion.answers.map((answer) => [...answer]);
373+
if (!currentQuestion.multiple && option) {
374+
answers[pendingQuestion.currentIndex] = [option.label];
375+
}
376+
if (
377+
currentQuestion.multiple &&
378+
(answers[pendingQuestion.currentIndex]?.length ?? 0) === 0
379+
) {
380+
return;
381+
}
382+
383+
const nextIndex = pendingQuestion.currentIndex + 1;
384+
if (nextIndex < pendingQuestion.questions.length) {
385+
setPendingQuestion({
386+
...pendingQuestion,
387+
answers,
388+
currentIndex: nextIndex,
389+
selectedIndex: 0,
390+
});
391+
} else {
392+
void submitQuestionAnswers(pendingQuestion, answers);
393+
}
394+
return;
395+
}
396+
397+
return;
398+
}
399+
242400
if (event.name === "escape") {
243401
if (!isLoading) {
244402
onBack();
@@ -264,7 +422,7 @@ export function Chat({
264422

265423
const sendMessage = useCallback(
266424
async (rawValue: string) => {
267-
if (sendLockRef.current || !hydrated) {
425+
if (sendLockRef.current || pendingQuestion || !hydrated) {
268426
return;
269427
}
270428

@@ -313,9 +471,22 @@ export function Chat({
313471
setIsLoading(false);
314472
}
315473
},
316-
[instanceId, sessionId, isLoading, hydrated, consumeStream],
474+
[
475+
instanceId,
476+
sessionId,
477+
isLoading,
478+
hydrated,
479+
pendingQuestion,
480+
consumeStream,
481+
],
317482
);
318483

484+
const activeQuestion = pendingQuestion
485+
? pendingQuestion.questions[pendingQuestion.currentIndex]
486+
: undefined;
487+
const activeAnswers =
488+
pendingQuestion?.answers[pendingQuestion.currentIndex] ?? [];
489+
319490
return (
320491
<box flexDirection="column" flexGrow={1} width="100%">
321492
<box flexShrink={0} height={1} width="100%">
@@ -366,15 +537,59 @@ export function Chat({
366537
})}
367538
</scrollbox>
368539

540+
{pendingQuestion && activeQuestion ? (
541+
<box
542+
position="absolute"
543+
top="20%"
544+
left="15%"
545+
width="70%"
546+
flexDirection="column"
547+
border={true}
548+
borderStyle="double"
549+
borderColor="#facc15"
550+
padding={1}
551+
backgroundColor="#111827"
552+
>
553+
<text attributes={TextAttributes.BOLD}>
554+
{`Question ${pendingQuestion.currentIndex + 1}/${pendingQuestion.questions.length}: ${activeQuestion.header}`}
555+
</text>
556+
<text>{activeQuestion.question}</text>
557+
{activeQuestion.options.map((option, index) => {
558+
const focused = index === pendingQuestion.selectedIndex;
559+
const selected = activeAnswers.includes(option.label);
560+
const marker = activeQuestion.multiple
561+
? selected
562+
? "[x]"
563+
: "[ ]"
564+
: focused
565+
? "(*)"
566+
: "( )";
567+
return (
568+
<text
569+
key={option.label}
570+
attributes={focused ? TextAttributes.BOLD : TextAttributes.DIM}
571+
>
572+
{`${focused ? ">" : " "} ${marker} ${option.label} - ${option.description}`}
573+
</text>
574+
);
575+
})}
576+
<text attributes={TextAttributes.DIM}>
577+
{activeQuestion.multiple
578+
? "j/k: move space: toggle enter: submit"
579+
: "j/k: move enter: select"}
580+
</text>
581+
</box>
582+
) : null}
583+
369584
<box
370585
flexShrink={0}
371586
height={3}
372587
width="100%"
373588
border={true}
374-
borderColor="#ffffff"
589+
borderColor={pendingQuestion ? "#666666" : "#ffffff"}
375590
>
376591
<input
377-
focused={true}
592+
focused={!pendingQuestion}
378593
value={inputValue}
379594
placeholder={placeholder}
380595
onInput={setInputValue}

‎packages/daemon/src/__tests__/daemon.test.ts‎

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -890,6 +890,85 @@ describe("Daemon streaming", () => {
890890
expect(job.outputText).toBe("foo bar baz");
891891
});
892892

893+
test("routes OpenCode question events to the matching job stream", async () => {
894+
const { instanceId, jobId } = await createInstanceAndSubmit("needs input");
895+
const events: Array<{ type: string; question?: string }> = [];
896+
const unsub = daemon.subscribeJob(jobId, (event) => {
897+
events.push(
898+
event.type === "question"
899+
? { type: event.type, question: event.questions[0]?.question }
900+
: { type: event.type },
901+
);
902+
});
903+
904+
for (let i = 0; i < 20 && registry.promptCalls.length === 0; i += 1) {
905+
await Bun.sleep(10);
906+
}
907+
const sessionId = registry.promptCalls[0]?.sessionId;
908+
if (!sessionId) {
909+
throw new Error("expected prompt call");
910+
}
911+
912+
registry.emitEvent(instanceId, {
913+
type: "question.asked",
914+
properties: {
915+
id: "question-1",
916+
sessionID: sessionId,
917+
questions: [
918+
{
919+
header: "Choice",
920+
question: "Which path should I take?",
921+
options: [
922+
{ label: "A", description: "Use option A" },
923+
{ label: "B", description: "Use option B" },
924+
],
925+
},
926+
],
927+
},
928+
});
929+
930+
for (
931+
let i = 0;
932+
i < 20 && !events.some((event) => event.type === "question");
933+
i += 1
934+
) {
935+
await Bun.sleep(10);
936+
}
937+
unsub();
938+
939+
expect(events).toContainEqual({
940+
type: "question",
941+
question: "Which path should I take?",
942+
});
943+
});
944+
945+
test("replies to OpenCode question requests through the runtime", async () => {
946+
const { instanceId } = await createInstanceAndSubmit("needs answer");
947+
for (let i = 0; i < 20 && registry.promptCalls.length === 0; i += 1) {
948+
await Bun.sleep(10);
949+
}
950+
951+
const response = await daemon.handleRequest(
952+
req({
953+
id: "question-reply",
954+
method: "question.reply",
955+
params: {
956+
instanceId,
957+
requestId: "question-1",
958+
answers: [["A"]],
959+
},
960+
}),
961+
);
962+
963+
expect(expectSuccess(response, "question.reply")).toEqual({ ok: true });
964+
expect(registry.questionReplyCalls).toContainEqual({
965+
instanceId,
966+
requestId: "question-1",
967+
directory: "/tmp/project-one",
968+
answers: [["A"]],
969+
});
970+
});
971+
893972
test("executeJob preserves accumulated text rather than overwriting with parts", async () => {
894973
registry.streamingDeltas = ["a", "b", "c"];
895974
registry.deltaIntervalMs = 25;

0 commit comments

Comments
 (0)