paviko 4 недель назад
Родитель
Сommit
513656a078

+ 1 - 0
hosts/jetbrains-plugin/changelog.html

@@ -2,6 +2,7 @@
 
 <h3>26.1.xx</h3>
 <ul>
+  <li>Added support for "Question" tool</li>
   <li>Added model "variants" - reasoning effort</li>
   <li>Fixed some models name on Recent list</li>
   <li>Updated OpenCode to v1.1.24</li>

+ 1 - 0
hosts/vscode-plugin/CHANGELOG.md

@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec2.0.0.h
 
 ### [26.1.xx] - 2026-01-xx
 
+- Added support for "Question" tool
 - Added model "variants" - reasoning effort
 - Fixed some models name on Recent list
 - Updated OpenCode to v1.1.24

+ 8 - 1
packages/opencode/webgui/src/components/MessageList/MessagePart.tsx

@@ -1,4 +1,4 @@
-import type { Part, WebguiPart } from "../../state/MessagesContext"
+import type { Part, WebguiPart, QuestionRequestPart as QuestionRequestPartType } from "../../state/MessagesContext"
 import { TextPart } from "./TextPart"
 import { ReasoningPart } from "./ReasoningPart"
 import { ToolPart } from "../parts/ToolPart"
@@ -6,6 +6,7 @@ import { PatchPart } from "../parts/PatchPart"
 import { SnapshotPart } from "../parts/SnapshotPart"
 import { RetryPart } from "../parts/RetryPart"
 import { SessionErrorPart } from "./SessionErrorPart"
+import { QuestionPart } from "./Parts/QuestionPart"
 
 interface MessagePartProps {
   part: WebguiPart
@@ -159,5 +160,11 @@ export function MessagePart({
     return <SessionErrorPart key={part.id} part={part} />
   }
 
+  // Question requests
+  if (part.type === "question") {
+    const questionPart = part as QuestionRequestPartType
+    return <QuestionPart key={part.id} request={questionPart.request} />
+  }
+
   return null
 }

+ 78 - 0
packages/opencode/webgui/src/components/MessageList/Parts/QuestionPart/ConfirmTab.tsx

@@ -0,0 +1,78 @@
+import { cn } from "../../../../utils/classNames"
+import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"
+
+interface ConfirmTabProps {
+  questions: QuestionInfo[]
+  answers: string[][]
+  onSubmit: () => void
+  onDismiss: () => void
+  isLoading: boolean
+}
+
+export function ConfirmTab({ questions, answers, onSubmit, onDismiss, isLoading }: ConfirmTabProps) {
+  const allAnswered = questions.every((_, index) => (answers[index]?.length ?? 0) > 0)
+
+  return (
+    <div className="px-3 py-2">
+      {/* Review header */}
+      <div className="text-sm font-medium text-gray-800 dark:text-gray-200 mb-3">Review your answers</div>
+
+      {/* Summary of answers */}
+      <div className="space-y-2 mb-4">
+        {questions.map((question, index) => {
+          const questionAnswers = answers[index] ?? []
+          const hasAnswer = questionAnswers.length > 0
+
+          return (
+            <div
+              key={index}
+              className={cn(
+                "p-2 rounded border",
+                hasAnswer
+                  ? "border-[#e4e9f2] dark:border-gray-700 bg-white dark:bg-gray-900"
+                  : "border-amber-300 dark:border-amber-700 bg-amber-50 dark:bg-amber-900/20"
+              )}
+            >
+              <div className="text-xs text-gray-500 dark:text-gray-400 mb-1">{question.header}</div>
+              {hasAnswer ? (
+                <div className="text-sm text-gray-800 dark:text-gray-200">{questionAnswers.join(", ")}</div>
+              ) : (
+                <div className="text-sm text-amber-600 dark:text-amber-400">(not answered)</div>
+              )}
+            </div>
+          )
+        })}
+      </div>
+
+      {/* Action buttons */}
+      <div className="flex gap-2">
+        <button
+          onClick={onSubmit}
+          disabled={isLoading || !allAnswered}
+          className={cn(
+            "px-3 py-1.5 text-xs rounded font-medium transition-colors",
+            allAnswered
+              ? "bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50"
+              : "bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed"
+          )}
+        >
+          {isLoading ? "Submitting..." : "Submit"}
+        </button>
+        <button
+          onClick={onDismiss}
+          disabled={isLoading}
+          className="px-3 py-1.5 text-xs rounded font-medium bg-gray-200 dark:bg-gray-800 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-700 disabled:opacity-50 transition-colors"
+        >
+          Dismiss
+        </button>
+      </div>
+
+      {/* Help text */}
+      {!allAnswered && (
+        <p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
+          Please answer all questions before submitting.
+        </p>
+      )}
+    </div>
+  )
+}

+ 215 - 0
packages/opencode/webgui/src/components/MessageList/Parts/QuestionPart/QuestionOptions.tsx

@@ -0,0 +1,215 @@
+import { cn } from "../../../../utils/classNames"
+import type { QuestionInfo } from "@opencode-ai/sdk/v2/client"
+
+interface QuestionOptionsProps {
+  question: QuestionInfo
+  answers: string[]
+  customInput: string
+  onToggleOption: (label: string) => void
+  onCustomInputChange: (value: string) => void
+  isCustomSelected: boolean
+  onSelectCustom: () => void
+  isEditing: boolean
+  onStartEditing: () => void
+  onFinishEditing: () => void
+}
+
+export function QuestionOptions({
+  question,
+  answers,
+  customInput,
+  onToggleOption,
+  onCustomInputChange,
+  isCustomSelected,
+  onSelectCustom,
+  isEditing,
+  onStartEditing,
+  onFinishEditing,
+}: QuestionOptionsProps) {
+  const isMultiple = question.multiple === true
+  const allowCustom = question.custom !== false
+
+  const handleOptionClick = (label: string) => {
+    onToggleOption(label)
+  }
+
+  const handleCustomClick = () => {
+    if (!isEditing) {
+      onSelectCustom()
+      onStartEditing()
+    }
+  }
+
+  const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
+    if (e.key === "Enter" && !e.shiftKey) {
+      e.preventDefault()
+      onFinishEditing()
+    }
+    if (e.key === "Escape") {
+      e.preventDefault()
+      onFinishEditing()
+    }
+  }
+
+  return (
+    <div className="px-3 py-2">
+      {/* Question text */}
+      <div className="text-sm text-gray-800 dark:text-gray-200 mb-3">
+        {question.question}
+        {isMultiple && (
+          <span className="text-gray-500 dark:text-gray-400 text-xs ml-2">(select all that apply)</span>
+        )}
+      </div>
+
+      {/* Options list */}
+      <div className="space-y-2">
+        {question.options.map((option, index) => {
+          const isSelected = answers.includes(option.label)
+          return (
+            <button
+              key={index}
+              onClick={() => handleOptionClick(option.label)}
+              className={cn(
+                "w-full text-left p-2 rounded border transition-colors",
+                isSelected
+                  ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
+                  : "border-[#e4e9f2] dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-900"
+              )}
+            >
+              <div className="flex items-start gap-2">
+                <span className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 w-4 flex-shrink-0">
+                  {index + 1}.
+                </span>
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2">
+                    {isMultiple ? (
+                      <span
+                        className={cn(
+                          "w-4 h-4 flex items-center justify-center border rounded text-xs",
+                          isSelected
+                            ? "border-blue-500 bg-blue-500 text-white"
+                            : "border-gray-300 dark:border-gray-600"
+                        )}
+                      >
+                        {isSelected && "✓"}
+                      </span>
+                    ) : (
+                      <span
+                        className={cn(
+                          "w-4 h-4 flex items-center justify-center border rounded-full",
+                          isSelected
+                            ? "border-blue-500 bg-blue-500"
+                            : "border-gray-300 dark:border-gray-600"
+                        )}
+                      >
+                        {isSelected && <span className="w-2 h-2 bg-white rounded-full" />}
+                      </span>
+                    )}
+                    <span
+                      className={cn(
+                        "text-sm font-medium",
+                        isSelected ? "text-blue-700 dark:text-blue-300" : "text-gray-800 dark:text-gray-200"
+                      )}
+                    >
+                      {option.label}
+                    </span>
+                    {!isMultiple && isSelected && (
+                      <span className="text-green-600 dark:text-green-400 text-xs">✓</span>
+                    )}
+                  </div>
+                  {option.description && (
+                    <p className="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-6">{option.description}</p>
+                  )}
+                </div>
+              </div>
+            </button>
+          )
+        })}
+
+        {/* Custom input option */}
+        {allowCustom && (
+          <div
+            className={cn(
+              "w-full text-left p-2 rounded border transition-colors",
+              isCustomSelected
+                ? "border-blue-500 bg-blue-50 dark:bg-blue-900/20"
+                : "border-[#e4e9f2] dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 bg-white dark:bg-gray-900"
+            )}
+          >
+            <button onClick={handleCustomClick} className="w-full text-left">
+              <div className="flex items-start gap-2">
+                <span className="text-xs text-gray-400 dark:text-gray-500 mt-0.5 w-4 flex-shrink-0">
+                  {question.options.length + 1}.
+                </span>
+                <div className="flex-1 min-w-0">
+                  <div className="flex items-center gap-2">
+                    {isMultiple ? (
+                      <span
+                        className={cn(
+                          "w-4 h-4 flex items-center justify-center border rounded text-xs",
+                          isCustomSelected
+                            ? "border-blue-500 bg-blue-500 text-white"
+                            : "border-gray-300 dark:border-gray-600"
+                        )}
+                      >
+                        {isCustomSelected && "✓"}
+                      </span>
+                    ) : (
+                      <span
+                        className={cn(
+                          "w-4 h-4 flex items-center justify-center border rounded-full",
+                          isCustomSelected
+                            ? "border-blue-500 bg-blue-500"
+                            : "border-gray-300 dark:border-gray-600"
+                        )}
+                      >
+                        {isCustomSelected && <span className="w-2 h-2 bg-white rounded-full" />}
+                      </span>
+                    )}
+                    <span
+                      className={cn(
+                        "text-sm font-medium",
+                        isCustomSelected ? "text-blue-700 dark:text-blue-300" : "text-gray-800 dark:text-gray-200"
+                      )}
+                    >
+                      Type your own answer
+                    </span>
+                    {!isMultiple && isCustomSelected && customInput && (
+                      <span className="text-green-600 dark:text-green-400 text-xs">✓</span>
+                    )}
+                  </div>
+                </div>
+              </div>
+            </button>
+
+            {/* Custom input textarea */}
+            {isEditing && (
+              <div className="mt-2 ml-6">
+                <textarea
+                  autoFocus
+                  value={customInput}
+                  onChange={(e) => onCustomInputChange(e.target.value)}
+                  onKeyDown={handleKeyDown}
+                  onBlur={onFinishEditing}
+                  placeholder="Type your own answer..."
+                  className="w-full px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-800 dark:text-gray-200 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500 resize-none"
+                  rows={2}
+                />
+                <p className="text-xs text-gray-400 dark:text-gray-500 mt-1">
+                  Press Enter to confirm, Escape to cancel
+                </p>
+              </div>
+            )}
+
+            {/* Show custom input value when not editing */}
+            {!isEditing && customInput && (
+              <div className="mt-1 ml-6">
+                <p className="text-xs text-gray-500 dark:text-gray-400">{customInput}</p>
+              </div>
+            )}
+          </div>
+        )}
+      </div>
+    </div>
+  )
+}

+ 50 - 0
packages/opencode/webgui/src/components/MessageList/Parts/QuestionPart/QuestionTabs.tsx

@@ -0,0 +1,50 @@
+import { cn } from "../../../../utils/classNames"
+
+interface QuestionTabsProps {
+  tabs: Array<{ header: string; answered: boolean }>
+  activeTab: number
+  onTabChange: (index: number) => void
+  showConfirm: boolean
+}
+
+export function QuestionTabs({ tabs, activeTab, onTabChange, showConfirm }: QuestionTabsProps) {
+  const isConfirmTab = activeTab === tabs.length
+
+  return (
+    <div className="flex flex-row gap-1 px-3 py-2 border-b border-[#e4e9f2] dark:border-gray-800 bg-[#f8fafc] dark:bg-gray-900/50">
+      {tabs.map((tab, index) => {
+        const isActive = index === activeTab
+        return (
+          <button
+            key={index}
+            onClick={() => onTabChange(index)}
+            className={cn(
+              "px-2 py-1 text-xs rounded transition-colors",
+              isActive
+                ? "bg-blue-600 text-white"
+                : tab.answered
+                  ? "bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600"
+                  : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
+            )}
+          >
+            {tab.header}
+            {tab.answered && !isActive && <span className="ml-1 text-green-600 dark:text-green-400">✓</span>}
+          </button>
+        )
+      })}
+      {showConfirm && (
+        <button
+          onClick={() => onTabChange(tabs.length)}
+          className={cn(
+            "px-2 py-1 text-xs rounded transition-colors",
+            isConfirmTab
+              ? "bg-blue-600 text-white"
+              : "bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-700"
+          )}
+        >
+          Confirm
+        </button>
+      )}
+    </div>
+  )
+}

+ 314 - 0
packages/opencode/webgui/src/components/MessageList/Parts/QuestionPart/index.tsx

@@ -0,0 +1,314 @@
+import { useState, useCallback, useMemo } from "react"
+import type { QuestionRequest, QuestionAnswer } from "@opencode-ai/sdk/v2/client"
+import { QuestionTabs } from "./QuestionTabs"
+import { QuestionOptions } from "./QuestionOptions"
+import { ConfirmTab } from "./ConfirmTab"
+import { useMessages } from "../../../../state/MessagesContext"
+
+interface QuestionPartProps {
+  request: QuestionRequest
+}
+
+export function QuestionPart({ request }: QuestionPartProps) {
+  const { replyQuestion, rejectQuestion } = useMessages()
+  const questions = request.questions
+  const [activeTab, setActiveTab] = useState(0)
+  const [answers, setAnswers] = useState<QuestionAnswer[]>(() => questions.map(() => []))
+  const [customInputs, setCustomInputs] = useState<string[]>(() => questions.map(() => ""))
+  const [isLoading, setIsLoading] = useState(false)
+  const [editingCustom, setEditingCustom] = useState(false)
+
+  // Single question with single select = auto-submit mode
+  const isSingleQuestionSingleSelect = useMemo(
+    () => questions.length === 1 && questions[0]?.multiple !== true,
+    [questions]
+  )
+
+  // Show confirm tab for multiple questions or multi-select
+  const showConfirmTab = !isSingleQuestionSingleSelect
+
+  // Check if we're on the confirm tab
+  const isConfirmTab = activeTab === questions.length && showConfirmTab
+
+  // Current question (if not on confirm tab)
+  const currentQuestion = questions[activeTab]
+  const currentAnswers = answers[activeTab] ?? []
+  const currentCustomInput = customInputs[activeTab] ?? ""
+  const isMultiple = currentQuestion?.multiple === true
+
+  // Check if custom input is selected
+  const isCustomSelected = useMemo(() => {
+    if (!currentCustomInput) return false
+    return currentAnswers.includes(currentCustomInput)
+  }, [currentAnswers, currentCustomInput])
+
+  // Handle submitting answers
+  const handleSubmit = useCallback(async () => {
+    setIsLoading(true)
+    try {
+      await replyQuestion(request.id, answers)
+    } catch (error) {
+      console.error("[QuestionPart] Failed to submit answers:", error)
+    } finally {
+      setIsLoading(false)
+    }
+  }, [request.id, answers, replyQuestion])
+
+  // Handle rejecting/dismissing
+  const handleDismiss = useCallback(async () => {
+    setIsLoading(true)
+    try {
+      await rejectQuestion(request.id)
+    } catch (error) {
+      console.error("[QuestionPart] Failed to reject question:", error)
+    } finally {
+      setIsLoading(false)
+    }
+  }, [request.id, rejectQuestion])
+
+  // Handle option toggle
+  const handleToggleOption = useCallback(
+    (label: string) => {
+      setAnswers((prev) => {
+        const newAnswers = [...prev]
+        const currentAnswers = [...(newAnswers[activeTab] ?? [])]
+
+        if (isMultiple) {
+          // Multi-select: toggle the option
+          const index = currentAnswers.indexOf(label)
+          if (index === -1) {
+            currentAnswers.push(label)
+          } else {
+            currentAnswers.splice(index, 1)
+          }
+          newAnswers[activeTab] = currentAnswers
+        } else {
+          // Single-select: replace the answer
+          newAnswers[activeTab] = [label]
+
+          // If single question single select, auto-submit
+          if (isSingleQuestionSingleSelect) {
+            // Submit immediately
+            setIsLoading(true)
+            replyQuestion(request.id, [[label]])
+              .catch((error: unknown) => {
+                console.error("[QuestionPart] Failed to submit answer:", error)
+              })
+              .finally(() => {
+                setIsLoading(false)
+              })
+          } else {
+            // Move to next tab
+            setTimeout(() => {
+              setActiveTab((prev) => Math.min(prev + 1, questions.length))
+            }, 150)
+          }
+        }
+
+        return newAnswers
+      })
+    },
+    [activeTab, isMultiple, isSingleQuestionSingleSelect, request.id, questions.length, replyQuestion]
+  )
+
+  // Handle custom input change
+  const handleCustomInputChange = useCallback(
+    (value: string) => {
+      setCustomInputs((prev) => {
+        const newInputs = [...prev]
+        newInputs[activeTab] = value
+        return newInputs
+      })
+    },
+    [activeTab]
+  )
+
+  // Handle selecting custom option
+  const handleSelectCustom = useCallback(() => {
+    // Start editing when custom option is selected
+    setEditingCustom(true)
+  }, [])
+
+  // Handle finishing custom input editing
+  const handleFinishEditing = useCallback(() => {
+    setEditingCustom(false)
+    const value = currentCustomInput.trim()
+
+    if (!value) {
+      // Clear custom from answers if empty
+      setAnswers((prev) => {
+        const newAnswers = [...prev]
+        const oldCustom = customInputs[activeTab]
+        if (oldCustom) {
+          newAnswers[activeTab] = (newAnswers[activeTab] ?? []).filter((a) => a !== oldCustom)
+        }
+        return newAnswers
+      })
+      return
+    }
+
+    setAnswers((prev) => {
+      const newAnswers = [...prev]
+      const currentAnswers = [...(newAnswers[activeTab] ?? [])]
+
+      // Remove old custom value if exists
+      const oldCustom = customInputs[activeTab]
+      if (oldCustom && oldCustom !== value) {
+        const oldIndex = currentAnswers.indexOf(oldCustom)
+        if (oldIndex !== -1) {
+          currentAnswers.splice(oldIndex, 1)
+        }
+      }
+
+      if (isMultiple) {
+        // Multi-select: add custom value if not already there
+        if (!currentAnswers.includes(value)) {
+          currentAnswers.push(value)
+        }
+        newAnswers[activeTab] = currentAnswers
+      } else {
+        // Single-select: replace with custom value
+        newAnswers[activeTab] = [value]
+
+        // If single question single select, auto-submit
+        if (isSingleQuestionSingleSelect) {
+          setIsLoading(true)
+          replyQuestion(request.id, [[value]])
+            .catch((error: unknown) => {
+              console.error("[QuestionPart] Failed to submit custom answer:", error)
+            })
+            .finally(() => {
+              setIsLoading(false)
+            })
+        } else {
+          // Move to next tab
+          setTimeout(() => {
+            setActiveTab((prev) => Math.min(prev + 1, questions.length))
+          }, 150)
+        }
+      }
+
+      return newAnswers
+    })
+  }, [
+    currentCustomInput,
+    activeTab,
+    customInputs,
+    isMultiple,
+    isSingleQuestionSingleSelect,
+    request.id,
+    questions.length,
+    replyQuestion,
+  ])
+
+  // Build tabs data
+  const tabsData = questions.map((q, index) => ({
+    header: q.header,
+    answered: (answers[index]?.length ?? 0) > 0,
+  }))
+
+  // Navigation handlers
+  const handlePrevious = () => {
+    setActiveTab((prev) => Math.max(prev - 1, 0))
+  }
+
+  const handleNext = () => {
+    setActiveTab((prev) => Math.min(prev + 1, showConfirmTab ? questions.length : questions.length - 1))
+  }
+
+  return (
+    <div className="my-0.5 border rounded-lg border-blue-300 dark:border-blue-700 overflow-hidden bg-[#fbfdff] dark:bg-gray-900">
+      {/* Header */}
+      <div className="px-3 py-2 bg-blue-50 dark:bg-blue-900/20 border-b border-blue-200 dark:border-blue-800">
+        <div className="text-xs font-medium text-blue-700 dark:text-blue-300">Question from assistant</div>
+      </div>
+
+      {/* Tabs (only show if multiple questions or multi-select) */}
+      {showConfirmTab && (
+        <QuestionTabs
+          tabs={tabsData}
+          activeTab={activeTab}
+          onTabChange={setActiveTab}
+          showConfirm={showConfirmTab}
+        />
+      )}
+
+      {/* Content */}
+      {isConfirmTab ? (
+        <ConfirmTab
+          questions={questions}
+          answers={answers}
+          onSubmit={handleSubmit}
+          onDismiss={handleDismiss}
+          isLoading={isLoading}
+        />
+      ) : (
+        currentQuestion && (
+          <QuestionOptions
+            question={currentQuestion}
+            answers={currentAnswers}
+            customInput={currentCustomInput}
+            onToggleOption={handleToggleOption}
+            onCustomInputChange={handleCustomInputChange}
+            isCustomSelected={isCustomSelected}
+            onSelectCustom={handleSelectCustom}
+            isEditing={editingCustom}
+            onStartEditing={() => setEditingCustom(true)}
+            onFinishEditing={handleFinishEditing}
+          />
+        )
+      )}
+
+      {/* Footer with navigation (only for non-single-select mode) */}
+      {!isSingleQuestionSingleSelect && (
+        <div className="px-3 py-2 border-t border-[#e4e9f2] dark:border-gray-800 bg-[#f8fafc] dark:bg-gray-900/50 flex items-center justify-between">
+          <div className="flex gap-2">
+            {activeTab > 0 && (
+              <button
+                onClick={handlePrevious}
+                className="px-2 py-1 text-xs rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+              >
+                ← Previous
+              </button>
+            )}
+            {!isConfirmTab && (
+              <button
+                onClick={handleNext}
+                className="px-2 py-1 text-xs rounded bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
+              >
+                Next →
+              </button>
+            )}
+          </div>
+
+          <div className="flex gap-2 text-xs text-gray-500 dark:text-gray-400">
+            <span>
+              {isConfirmTab ? "Review" : `${activeTab + 1}/${questions.length}`}
+            </span>
+            <span>•</span>
+            <button
+              onClick={handleDismiss}
+              disabled={isLoading}
+              className="hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
+            >
+              Dismiss
+            </button>
+          </div>
+        </div>
+      )}
+
+      {/* Simple footer for single-select single-question */}
+      {isSingleQuestionSingleSelect && (
+        <div className="px-3 py-2 border-t border-[#e4e9f2] dark:border-gray-800 bg-[#f8fafc] dark:bg-gray-900/50 flex items-center justify-end">
+          <button
+            onClick={handleDismiss}
+            disabled={isLoading}
+            className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
+          >
+            Dismiss
+          </button>
+        </div>
+      )}
+    </div>
+  )
+}

+ 13 - 1
packages/opencode/webgui/src/components/MessageList/index.tsx

@@ -7,6 +7,7 @@ import { EmptyState } from "./EmptyState"
 import { MessageRow } from "./MessageRow"
 import { RevertBanner } from "./RevertBanner"
 import { RevertSummary } from "./RevertSummary"
+import { QuestionPart } from "./Parts/QuestionPart"
 import { useMessageScroll } from "./hooks/useMessageScroll"
 import { useMessageActions } from "./hooks/useMessageActions"
 
@@ -16,9 +17,12 @@ interface MessageListProps {
 }
 
 export function MessageList({ sessionID, onUndoToInput }: MessageListProps) {
-  const { getMessagesBySession, messages } = useMessages()
+  const { getMessagesBySession, messages, getQuestionsBySession } = useMessages()
   const { isIdle, isReasoning, currentSession } = useSession()
 
+  // Get pending questions for current session
+  const pendingQuestions = sessionID ? getQuestionsBySession(sessionID) : []
+
   // Debug logging for isIdle state
   useEffect(() => {
     console.log("[MessageList] isIdle state changed:", isIdle)
@@ -136,6 +140,14 @@ export function MessageList({ sessionID, onUndoToInput }: MessageListProps) {
           )}
 
           {rows}
+
+          {/* Pending questions from server */}
+          {pendingQuestions.map((question) => (
+            <div key={question.id} className="px-4">
+              <QuestionPart request={question} />
+            </div>
+          ))}
+
           {/* Typing indicator - hide while reasoning parts are streaming */}
           <TypingIndicator visible={!isIdle && !isReasoning} />
           {/* Scroll anchor */}

+ 3 - 0
packages/opencode/webgui/src/lib/api/events.ts

@@ -27,6 +27,9 @@ export type ServerEvent =
   | { type: "message.part.removed"; properties: { sessionID: string; messageID: string; partID: string } }
   | { type: "permission.asked"; properties: any }
   | { type: "permission.replied"; properties: any }
+  | { type: "question.asked"; properties: any }
+  | { type: "question.replied"; properties: { sessionID: string; requestID: string; answers: any[] } }
+  | { type: "question.rejected"; properties: { sessionID: string; requestID: string } }
   | { type: "file.edited"; properties: any }
   | { type: "file.updated"; properties: any }
   | { type: "ide.installed"; properties: any }

+ 25 - 0
packages/opencode/webgui/src/lib/api/sdkClient.ts

@@ -194,6 +194,31 @@ export const sdk = {
       return { data, error: null }
     },
   },
+  question: {
+    reply: async (options: { requestID: string; answers: Array<Array<string>> }) => {
+      const response = await fetch(`/question/${options.requestID}/reply`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+        body: JSON.stringify({ answers: options.answers }),
+      })
+      if (!response.ok) {
+        return { error: { message: "Failed to reply to question" }, data: null }
+      }
+      const data = await response.json()
+      return { data, error: null }
+    },
+    reject: async (options: { requestID: string }) => {
+      const response = await fetch(`/question/${options.requestID}/reject`, {
+        method: "POST",
+        headers: { "Content-Type": "application/json" },
+      })
+      if (!response.ok) {
+        return { error: { message: "Failed to reject question" }, data: null }
+      }
+      const data = await response.json()
+      return { data, error: null }
+    },
+  },
   state: {
     get: async () => {
       try {

+ 143 - 3
packages/opencode/webgui/src/state/MessagesContext.tsx

@@ -1,6 +1,7 @@
 import { createContext, useContext, useState, useCallback, type ReactNode } from "react"
 import { useEventHandler, type EventEmitter, type ServerEvent } from "../lib/api/events"
-import type { Message, Part, WebguiPart, SDKMessage } from "../types/messages"
+import type { Message, Part, WebguiPart, SDKMessage, QuestionRequest } from "../types/messages"
+import type { QuestionAnswer } from "@opencode-ai/sdk/v2/client"
 // PermissionRequest type based on new permission system (permission.asked event)
 interface PermissionRequest {
   id: string
@@ -20,7 +21,7 @@ import { useSession } from "./SessionContext"
 import { reloadPath } from "../lib/ideBridge"
 
 // Re-export types for convenience
-export type { Message, Part, WebguiPart, SDKMessage } from "../types/messages"
+export type { Message, Part, WebguiPart, SDKMessage, QuestionRequest, QuestionRequestPart } from "../types/messages"
 
 interface MessagesContextValue {
   messages: Message[]
@@ -43,6 +44,12 @@ interface MessagesContextValue {
     requestID: string,
     reply: "once" | "always" | "reject",
   ) => Promise<boolean>
+  // questions
+  questions: Map<string, QuestionRequest[]>
+  getQuestionsBySession: (sessionID: string) => QuestionRequest[]
+  getQuestionForCall: (sessionID: string, callID?: string | null) => QuestionRequest | undefined
+  replyQuestion: (requestID: string, answers: QuestionAnswer[]) => Promise<boolean>
+  rejectQuestion: (requestID: string) => Promise<boolean>
 }
 
 const MessagesContext = createContext<MessagesContextValue | undefined>(undefined)
@@ -55,6 +62,7 @@ interface MessagesProviderProps {
 export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
   const [messages, setMessages] = useState<Message[]>([])
   const [permissions, setPermissions] = useState<PermissionRequest[]>([])
+  const [questions, setQuestions] = useState<Map<string, QuestionRequest[]>>(new Map())
   const session = useSession()
   const setReasoning = session.setReasoning
 
@@ -270,7 +278,8 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
       if (response.data) {
         console.log("[MessagesContext] Messages loaded:", response.data.length)
         // SDK response is already in the correct format: Array<{ info: Message, parts: Array<Part> }>
-        const loadedMessages: Message[] = response.data
+        // Cast needed because sdk.session.messages returns non-v2 types, but they're structurally identical
+        const loadedMessages = response.data as unknown as Message[]
 
         console.log("[MessagesContext] Loaded messages sample:", loadedMessages[0])
 
@@ -342,6 +351,129 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
     [],
   )
 
+  // Question events
+  const handleQuestionAsked = useCallback((event: ServerEvent) => {
+    if (event.type !== "question.asked") return
+    const request = event.properties as QuestionRequest
+    console.log("[MessagesContext] Question asked:", request.id, request.sessionID)
+    setQuestions((prev) => {
+      const newMap = new Map(prev)
+      const sessionQuestions = newMap.get(request.sessionID) ?? []
+      const exists = sessionQuestions.some((q) => q.id === request.id)
+      if (exists) {
+        // Update existing question
+        newMap.set(
+          request.sessionID,
+          sessionQuestions.map((q) => (q.id === request.id ? request : q)),
+        )
+      } else {
+        // Add new question
+        newMap.set(request.sessionID, [...sessionQuestions, request])
+      }
+      return newMap
+    })
+  }, [])
+
+  const handleQuestionReplied = useCallback((event: ServerEvent) => {
+    if (event.type !== "question.replied") return
+    const { sessionID, requestID } = event.properties as { sessionID: string; requestID: string }
+    console.log("[MessagesContext] Question replied:", requestID, sessionID)
+    setQuestions((prev) => {
+      const newMap = new Map(prev)
+      const sessionQuestions = newMap.get(sessionID)
+      if (sessionQuestions) {
+        newMap.set(
+          sessionID,
+          sessionQuestions.filter((q) => q.id !== requestID),
+        )
+      }
+      return newMap
+    })
+  }, [])
+
+  const handleQuestionRejected = useCallback((event: ServerEvent) => {
+    if (event.type !== "question.rejected") return
+    const { sessionID, requestID } = event.properties as { sessionID: string; requestID: string }
+    console.log("[MessagesContext] Question rejected:", requestID, sessionID)
+    setQuestions((prev) => {
+      const newMap = new Map(prev)
+      const sessionQuestions = newMap.get(sessionID)
+      if (sessionQuestions) {
+        newMap.set(
+          sessionID,
+          sessionQuestions.filter((q) => q.id !== requestID),
+        )
+      }
+      return newMap
+    })
+  }, [])
+
+  const getQuestionsBySession = useCallback(
+    (sessionID: string): QuestionRequest[] => {
+      return questions.get(sessionID) ?? []
+    },
+    [questions],
+  )
+
+  const getQuestionForCall = useCallback(
+    (sessionID: string, callID?: string | null): QuestionRequest | undefined => {
+      if (!sessionID || !callID) return undefined
+      const sessionQuestions = questions.get(sessionID)
+      if (!sessionQuestions) return undefined
+      return sessionQuestions.find((q) => q.tool?.callID === callID)
+    },
+    [questions],
+  )
+
+  const replyQuestion = useCallback(async (requestID: string, answers: QuestionAnswer[]): Promise<boolean> => {
+    try {
+      await sdk.question.reply({
+        requestID,
+        answers,
+      })
+      // Remove from local state (event will also do this, but be proactive)
+      setQuestions((prev) => {
+        const newMap = new Map(prev)
+        for (const [sessionID, sessionQuestions] of newMap) {
+          const filtered = sessionQuestions.filter((q) => q.id !== requestID)
+          if (filtered.length !== sessionQuestions.length) {
+            newMap.set(sessionID, filtered)
+            break
+          }
+        }
+        return newMap
+      })
+      return true
+    } catch (e) {
+      console.error("[MessagesContext] Failed to reply to question:", e)
+      return false
+    }
+  }, [])
+
+  const rejectQuestion = useCallback(async (requestID: string): Promise<boolean> => {
+    try {
+      await sdk.question.reject({
+        requestID,
+      })
+      // Remove from local state (event will also do this, but be proactive)
+      setQuestions((prev) => {
+        const newMap = new Map(prev)
+        for (const [sessionID, sessionQuestions] of newMap) {
+          const filtered = sessionQuestions.filter((q) => q.id !== requestID)
+          if (filtered.length !== sessionQuestions.length) {
+            newMap.set(sessionID, filtered)
+            break
+          }
+        }
+        return newMap
+      })
+      return true
+    } catch (e) {
+      console.error("[MessagesContext] Failed to reject question:", e)
+      return false
+    }
+  }, [])
+
   // Subscribe to events if emitter is provided
   useEventHandler(emitter ?? null, "message.updated", handleMessageUpdated)
   useEventHandler(emitter ?? null, "message.part.updated", handlePartUpdated)
@@ -350,6 +482,9 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
   useEventHandler(emitter ?? null, "message.part.removed", handlePartRemoved)
   useEventHandler(emitter ?? null, "permission.asked", handlePermissionAsked)
   useEventHandler(emitter ?? null, "permission.replied", handlePermissionReplied)
+  useEventHandler(emitter ?? null, "question.asked", handleQuestionAsked)
+  useEventHandler(emitter ?? null, "question.replied", handleQuestionReplied)
+  useEventHandler(emitter ?? null, "question.rejected", handleQuestionRejected)
 
   const value: MessagesContextValue = {
     messages,
@@ -368,6 +503,11 @@ export function MessagesProvider({ children, emitter }: MessagesProviderProps) {
     permissions,
     getPermissionForCall,
     respondPermission,
+    questions,
+    getQuestionsBySession,
+    getQuestionForCall,
+    replyQuestion,
+    rejectQuestion,
   }
 
   return <MessagesContext.Provider value={value}>{children}</MessagesContext.Provider>

+ 12 - 2
packages/opencode/webgui/src/types/messages.ts

@@ -18,7 +18,8 @@ import type {
   StepStartPart,
   StepFinishPart,
   RetryPart,
-} from "@opencode-ai/sdk/client"
+  QuestionRequest,
+} from "@opencode-ai/sdk/v2/client"
 
 // Re-export SDK message types
 export type {
@@ -36,6 +37,7 @@ export type {
   StepFinishPart,
   RetryPart,
   SDKMessage,
+  QuestionRequest,
 }
 
 export interface SessionErrorPart {
@@ -46,7 +48,15 @@ export interface SessionErrorPart {
   message: string
 }
 
-export type WebguiPart = Part | SessionErrorPart
+export interface QuestionRequestPart {
+  type: "question"
+  id: string
+  sessionID: string
+  messageID: string
+  request: QuestionRequest
+}
+
+export type WebguiPart = Part | SessionErrorPart | QuestionRequestPart
 
 // SDK's Message is the discriminated union (UserMessage | AssistantMessage)
 // Webgui structure wraps this with parts array