11import type { ScrollBoxRenderable } from "@opentui/core" ;
22import { TextAttributes } from "@opentui/core" ;
33import { useKeyboard } from "@opentui/react" ;
4- import type { DaemonJob } from "@techatnyu/ralphd" ;
4+ import type { DaemonJob , QuestionInfo } from "@techatnyu/ralphd" ;
55import { daemon } from "@techatnyu/ralphd" ;
66import { useCallback , useEffect , useMemo , useRef , useState } from "react" ;
77import { 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+
1826let messageIdCounter = 0 ;
1927function 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. */
2461function 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 }
0 commit comments