mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-19 06:45:50 +00:00
465 lines
15 KiB
TypeScript
465 lines
15 KiB
TypeScript
import figures from 'figures'
|
|
import React, { useCallback, useMemo, useRef, useState } from 'react'
|
|
import { useTerminalSize } from '../../../hooks/useTerminalSize.js'
|
|
import { type KeyboardEvent, Box, Text } from '@anthropic/ink'
|
|
import {
|
|
useKeybinding,
|
|
useKeybindings,
|
|
} from '../../../keybindings/useKeybinding.js'
|
|
import { useAppState } from '../../../state/AppState.js'
|
|
import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'
|
|
import { getExternalEditor } from '../../../utils/editor.js'
|
|
import { toIDEDisplayName } from '../../../utils/ide.js'
|
|
import { editPromptInEditor } from '../../../utils/promptEditor.js'
|
|
import { Divider } from '@anthropic/ink'
|
|
import TextInput from '../../TextInput.js'
|
|
import { PermissionRequestTitle } from '../PermissionRequestTitle.js'
|
|
import { PreviewBox } from './PreviewBox.js'
|
|
import { QuestionNavigationBar } from './QuestionNavigationBar.js'
|
|
import type { QuestionState } from './use-multiple-choice-state.js'
|
|
|
|
type Props = {
|
|
question: Question
|
|
questions: Question[]
|
|
currentQuestionIndex: number
|
|
answers: Record<string, string>
|
|
questionStates: Record<string, QuestionState>
|
|
hideSubmitTab?: boolean
|
|
minContentHeight?: number
|
|
minContentWidth?: number
|
|
onUpdateQuestionState: (
|
|
questionText: string,
|
|
updates: Partial<QuestionState>,
|
|
isMultiSelect: boolean,
|
|
) => void
|
|
onAnswer: (
|
|
questionText: string,
|
|
label: string | string[],
|
|
textInput?: string,
|
|
shouldAdvance?: boolean,
|
|
) => void
|
|
onTextInputFocus: (isInInput: boolean) => void
|
|
onCancel: () => void
|
|
onTabPrev?: () => void
|
|
onTabNext?: () => void
|
|
onRespondToClaude: () => void
|
|
onFinishPlanInterview: () => void
|
|
}
|
|
|
|
/**
|
|
* A side-by-side question view for questions with preview content.
|
|
* Displays a vertical option list on the left with a preview panel on the right.
|
|
*/
|
|
export function PreviewQuestionView({
|
|
question,
|
|
questions,
|
|
currentQuestionIndex,
|
|
answers,
|
|
questionStates,
|
|
hideSubmitTab = false,
|
|
minContentHeight,
|
|
minContentWidth,
|
|
onUpdateQuestionState,
|
|
onAnswer,
|
|
onTextInputFocus,
|
|
onCancel,
|
|
onTabPrev,
|
|
onTabNext,
|
|
onRespondToClaude,
|
|
onFinishPlanInterview,
|
|
}: Props): React.ReactNode {
|
|
const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan'
|
|
const [isFooterFocused, setIsFooterFocused] = useState(false)
|
|
const [footerIndex, setFooterIndex] = useState(0)
|
|
const [isInNotesInput, setIsInNotesInput] = useState(false)
|
|
const [cursorOffset, setCursorOffset] = useState(0)
|
|
|
|
const editor = getExternalEditor()
|
|
const editorName = editor ? toIDEDisplayName(editor) : null
|
|
|
|
const questionText = question.question
|
|
const questionState = questionStates[questionText]
|
|
|
|
// Only real options — no "Other" for preview questions
|
|
const allOptions = question.options
|
|
|
|
// Track which option is focused (for preview display)
|
|
const [focusedIndex, setFocusedIndex] = useState(0)
|
|
|
|
// Reset focusedIndex when navigating to a different question
|
|
const prevQuestionText = useRef(questionText)
|
|
if (prevQuestionText.current !== questionText) {
|
|
prevQuestionText.current = questionText
|
|
const selected = questionState?.selectedValue as string | undefined
|
|
const idx = selected
|
|
? allOptions.findIndex(opt => opt.label === selected)
|
|
: -1
|
|
setFocusedIndex(idx >= 0 ? idx : 0)
|
|
}
|
|
|
|
const focusedOption = allOptions[focusedIndex]
|
|
const selectedValue = questionState?.selectedValue as string | undefined
|
|
const notesValue = questionState?.textInputValue || ''
|
|
|
|
const handleSelectOption = useCallback(
|
|
(index: number) => {
|
|
const option = allOptions[index]
|
|
if (!option) return
|
|
|
|
setFocusedIndex(index)
|
|
onUpdateQuestionState(
|
|
questionText,
|
|
{ selectedValue: option.label },
|
|
false,
|
|
)
|
|
|
|
onAnswer(questionText, option.label)
|
|
},
|
|
[allOptions, questionText, onUpdateQuestionState, onAnswer],
|
|
)
|
|
|
|
const handleNavigate = useCallback(
|
|
(direction: 'up' | 'down' | number) => {
|
|
if (isInNotesInput) return
|
|
|
|
let newIndex: number
|
|
if (typeof direction === 'number') {
|
|
newIndex = direction
|
|
} else if (direction === 'up') {
|
|
newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex
|
|
} else {
|
|
newIndex =
|
|
focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex
|
|
}
|
|
|
|
if (newIndex >= 0 && newIndex < allOptions.length) {
|
|
setFocusedIndex(newIndex)
|
|
}
|
|
},
|
|
[focusedIndex, allOptions.length, isInNotesInput],
|
|
)
|
|
|
|
// Handle ctrl+g to open external editor for notes
|
|
useKeybinding(
|
|
'chat:externalEditor',
|
|
async () => {
|
|
const currentValue = questionState?.textInputValue || ''
|
|
const result = await editPromptInEditor(currentValue)
|
|
if (result.content !== null && result.content !== currentValue) {
|
|
onUpdateQuestionState(
|
|
questionText,
|
|
{ textInputValue: result.content },
|
|
false,
|
|
)
|
|
}
|
|
},
|
|
{ context: 'Chat', isActive: isInNotesInput && !!editor },
|
|
)
|
|
|
|
// Handle left/right arrow and tab for question navigation.
|
|
// This must be in the child component (not just the parent) because child useInput
|
|
// handlers register first on the event emitter and fire before parent handlers.
|
|
// Without this, the parent's useKeybindings may not fire reliably depending on
|
|
// listener ordering in the event emitter.
|
|
useKeybindings(
|
|
{
|
|
'tabs:previous': () => onTabPrev?.(),
|
|
'tabs:next': () => onTabNext?.(),
|
|
},
|
|
{ context: 'Tabs', isActive: !isInNotesInput && !isFooterFocused },
|
|
)
|
|
|
|
// Re-submit the answer (plain label) when exiting notes input.
|
|
// Notes are stored in questionStates and collected at submit time via annotations.
|
|
const handleNotesExit = useCallback(() => {
|
|
setIsInNotesInput(false)
|
|
onTextInputFocus(false)
|
|
if (selectedValue) {
|
|
onAnswer(questionText, selectedValue)
|
|
}
|
|
}, [selectedValue, questionText, onAnswer, onTextInputFocus])
|
|
|
|
const handleDownFromPreview = useCallback(() => {
|
|
setIsFooterFocused(true)
|
|
}, [])
|
|
|
|
const handleUpFromFooter = useCallback(() => {
|
|
setIsFooterFocused(false)
|
|
}, [])
|
|
|
|
// Handle keyboard input for option/footer/notes navigation.
|
|
// Always active — the handler routes internally based on isFooterFocused/isInNotesInput.
|
|
const handleKeyDown = useCallback(
|
|
(e: KeyboardEvent) => {
|
|
if (isFooterFocused) {
|
|
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
|
e.preventDefault()
|
|
if (footerIndex === 0) {
|
|
handleUpFromFooter()
|
|
} else {
|
|
setFooterIndex(0)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
|
e.preventDefault()
|
|
if (isInPlanMode && footerIndex === 0) {
|
|
setFooterIndex(1)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'return') {
|
|
e.preventDefault()
|
|
if (footerIndex === 0) {
|
|
onRespondToClaude()
|
|
} else {
|
|
onFinishPlanInterview()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (e.key === 'escape') {
|
|
e.preventDefault()
|
|
onCancel()
|
|
}
|
|
return
|
|
}
|
|
|
|
if (isInNotesInput) {
|
|
// In notes input mode, handle escape to exit back to option navigation
|
|
if (e.key === 'escape') {
|
|
e.preventDefault()
|
|
handleNotesExit()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle option navigation (vertical)
|
|
if (e.key === 'up' || (e.ctrl && e.key === 'p')) {
|
|
e.preventDefault()
|
|
if (focusedIndex > 0) {
|
|
handleNavigate('up')
|
|
}
|
|
} else if (e.key === 'down' || (e.ctrl && e.key === 'n')) {
|
|
e.preventDefault()
|
|
if (focusedIndex === allOptions.length - 1) {
|
|
// At bottom of options, go to footer
|
|
handleDownFromPreview()
|
|
} else {
|
|
handleNavigate('down')
|
|
}
|
|
} else if (e.key === 'return') {
|
|
e.preventDefault()
|
|
handleSelectOption(focusedIndex)
|
|
} else if (e.key === 'n' && !e.ctrl && !e.meta) {
|
|
// Press 'n' to focus the notes input
|
|
e.preventDefault()
|
|
setIsInNotesInput(true)
|
|
onTextInputFocus(true)
|
|
} else if (e.key === 'escape') {
|
|
e.preventDefault()
|
|
onCancel()
|
|
} else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') {
|
|
e.preventDefault()
|
|
const idx = parseInt(e.key, 10) - 1
|
|
if (idx < allOptions.length) {
|
|
handleNavigate(idx)
|
|
}
|
|
}
|
|
},
|
|
[
|
|
isFooterFocused,
|
|
footerIndex,
|
|
isInPlanMode,
|
|
isInNotesInput,
|
|
focusedIndex,
|
|
allOptions.length,
|
|
handleUpFromFooter,
|
|
handleDownFromPreview,
|
|
handleNavigate,
|
|
handleSelectOption,
|
|
handleNotesExit,
|
|
onRespondToClaude,
|
|
onFinishPlanInterview,
|
|
onCancel,
|
|
onTextInputFocus,
|
|
],
|
|
)
|
|
|
|
const previewContent = focusedOption?.preview || null
|
|
|
|
// The right panel's available width is terminal minus the left panel and gap.
|
|
const LEFT_PANEL_WIDTH = 30
|
|
const GAP = 4
|
|
const { columns } = useTerminalSize()
|
|
const previewMaxWidth = columns - LEFT_PANEL_WIDTH - GAP
|
|
|
|
// Lines used within the content area that aren't preview content:
|
|
// 1: marginTop on side-by-side box
|
|
// 2: PreviewBox borders (top + bottom)
|
|
// 2: notes section (marginTop=1 + text)
|
|
// 2: footer section (marginTop=1 + divider)
|
|
// 1: "Chat about this" line
|
|
// 1: plan mode line (may or may not show)
|
|
// 2: help text (marginTop=1 + text)
|
|
const PREVIEW_OVERHEAD = 11
|
|
|
|
// Compute the max lines available for preview content from the parent's
|
|
// height budget to prevent terminal overflow. We do NOT pad shorter options
|
|
// to match the tallest — the outer box's minHeight handles cross-question
|
|
// layout consistency, and within-question shifts are acceptable.
|
|
const previewMaxLines = useMemo(() => {
|
|
return minContentHeight
|
|
? Math.max(1, minContentHeight - PREVIEW_OVERHEAD)
|
|
: undefined
|
|
}, [minContentHeight])
|
|
|
|
return (
|
|
<Box
|
|
flexDirection="column"
|
|
marginTop={1}
|
|
tabIndex={0}
|
|
autoFocus
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
<Divider color="inactive" />
|
|
<Box flexDirection="column" paddingTop={0}>
|
|
<QuestionNavigationBar
|
|
questions={questions}
|
|
currentQuestionIndex={currentQuestionIndex}
|
|
answers={answers}
|
|
hideSubmitTab={hideSubmitTab}
|
|
/>
|
|
<PermissionRequestTitle title={question.question} color={'text'} />
|
|
|
|
<Box flexDirection="column" minHeight={minContentHeight}>
|
|
{/* Side-by-side layout: options on left, preview on right */}
|
|
<Box marginTop={1} flexDirection="row" gap={4}>
|
|
{/* Left panel: vertical option list */}
|
|
<Box flexDirection="column" width={30}>
|
|
{allOptions.map((option, index) => {
|
|
const isFocused = focusedIndex === index
|
|
const isSelected = selectedValue === option.label
|
|
|
|
return (
|
|
<Box key={option.label} flexDirection="row">
|
|
{isFocused ? (
|
|
<Text color="suggestion">{figures.pointer}</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)}
|
|
<Text dimColor> {index + 1}.</Text>
|
|
<Text
|
|
color={
|
|
isSelected
|
|
? 'success'
|
|
: isFocused
|
|
? 'suggestion'
|
|
: undefined
|
|
}
|
|
bold={isFocused}
|
|
>
|
|
{' '}
|
|
{option.label}
|
|
</Text>
|
|
{isSelected && <Text color="success"> {figures.tick}</Text>}
|
|
</Box>
|
|
)
|
|
})}
|
|
</Box>
|
|
|
|
{/* Right panel: preview + notes */}
|
|
<Box flexDirection="column" flexGrow={1}>
|
|
<PreviewBox
|
|
content={previewContent || 'No preview available'}
|
|
maxLines={previewMaxLines}
|
|
minWidth={minContentWidth}
|
|
maxWidth={previewMaxWidth}
|
|
/>
|
|
<Box marginTop={1} flexDirection="row" gap={1}>
|
|
<Text color="suggestion">Notes:</Text>
|
|
{isInNotesInput ? (
|
|
<TextInput
|
|
value={notesValue}
|
|
placeholder="Add notes on this design…"
|
|
onChange={value => {
|
|
onUpdateQuestionState(
|
|
questionText,
|
|
{ textInputValue: value },
|
|
false,
|
|
)
|
|
}}
|
|
onSubmit={handleNotesExit}
|
|
onExit={handleNotesExit}
|
|
focus={true}
|
|
showCursor={true}
|
|
columns={60}
|
|
cursorOffset={cursorOffset}
|
|
onChangeCursorOffset={setCursorOffset}
|
|
/>
|
|
) : (
|
|
<Text dimColor italic>
|
|
{notesValue || 'press n to add notes'}
|
|
</Text>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
{/* Footer section */}
|
|
<Box flexDirection="column" marginTop={1}>
|
|
<Divider color="inactive" />
|
|
<Box flexDirection="row" gap={1}>
|
|
{isFooterFocused && footerIndex === 0 ? (
|
|
<Text color="suggestion">{figures.pointer}</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)}
|
|
<Text
|
|
color={
|
|
isFooterFocused && footerIndex === 0
|
|
? 'suggestion'
|
|
: undefined
|
|
}
|
|
>
|
|
Chat about this
|
|
</Text>
|
|
</Box>
|
|
{isInPlanMode && (
|
|
<Box flexDirection="row" gap={1}>
|
|
{isFooterFocused && footerIndex === 1 ? (
|
|
<Text color="suggestion">{figures.pointer}</Text>
|
|
) : (
|
|
<Text> </Text>
|
|
)}
|
|
<Text
|
|
color={
|
|
isFooterFocused && footerIndex === 1
|
|
? 'suggestion'
|
|
: undefined
|
|
}
|
|
>
|
|
Skip interview and plan immediately
|
|
</Text>
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
<Box marginTop={1}>
|
|
<Text color="inactive" dimColor>
|
|
Enter to select · {figures.arrowUp}/{figures.arrowDown} to
|
|
navigate · n to add notes
|
|
{questions.length > 1 && <> · Tab to switch questions</>}
|
|
{isInNotesInput && editorName && (
|
|
<> · ctrl+g to edit in {editorName}</>
|
|
)}{' '}
|
|
· Esc to cancel
|
|
</Text>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
)
|
|
}
|