Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions src/components/PromptInput/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ type Props = {
}, options?: {
fromKeybinding?: boolean;
}) => Promise<void>;
// Optional inline startup UI can consume Enter before prompt suggestions
// or the normal empty-input guard run. Returning true prevents a work turn.
onBeforeSubmit?: (input: string) => boolean;
onAgentSubmit?: (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => Promise<void>;
isSearchingHistory: boolean;
setIsSearchingHistory: (isSearching: boolean) => void;
Expand Down Expand Up @@ -224,6 +227,7 @@ function PromptInput({
onExit,
getToolUseContext,
onSubmit: onSubmitProp,
onBeforeSubmit,
onAgentSubmit,
isSearchingHistory,
setIsSearchingHistory,
Expand Down Expand Up @@ -1010,9 +1014,18 @@ function PromptInput({
return;
}

// Check for images early - we need this for suggestion logic below
// Check for images before optional startup UI interception: an image-only
// prompt is work and must always pass through untouched.
const hasImages = Object.values(pastedContents).some(c => c.type === 'image');

if (!hasImages && onBeforeSubmit?.(inputParam)) {
trackAndSetInput('');
setCursorOffset(0);
clearBuffer();
resetHistory();
return;
}

// If input is empty OR matches the suggestion, submit it
// But if there are images attached, don't auto-accept the suggestion -
// the user wants to submit just the image(s).
Expand Down Expand Up @@ -1111,7 +1124,7 @@ function PromptInput({
clearBuffer,
resetHistory
});
}, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification]);
}, [promptSuggestionState, speculation, speculationSessionTimeSavedMs, teamContext, store, footerItems, suggestionsState.suggestions, onSubmitProp, onBeforeSubmit, onAgentSubmit, clearBuffer, resetHistory, logOutcomeAtSubmission, setAppState, markAccepted, pastedContents, removeNotification, trackAndSetInput]);
const {
suggestions,
selectedSuggestion,
Expand Down
53 changes: 53 additions & 0 deletions src/components/VerbooFeedback/VerbooStartupFeedback.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useCallback } from 'react'

import { Box, Text } from '../../ink.js'
import { useDebouncedDigitInput } from '../FeedbackSurvey/useDebouncedDigitInput.js'
import type { VerbooStartupFeedbackState } from './useVerbooStartupFeedback.js'

type Props = Pick<VerbooStartupFeedbackState, 'offer' | 'questionIndex' | 'selectedOptionIds' | 'thanks' | 'handleDigit'> & {
inputValue: string
setInputValue: (value: string) => void
locale: 'pt' | 'en'
}

export function VerbooStartupFeedback({ offer, questionIndex, selectedOptionIds, thanks, handleDigit, inputValue, setInputValue, locale }: Props) {
const question = offer?.questions[questionIndex]
const copy = locale === 'en'
? {
thanks: 'Thanks for your feedback!',
skip: 'Skip',
multiple: (min: number, max: number) => `Choose ${min} to ${max} options and press Enter to confirm. `,
optional: 'Optional · start typing normally to continue your work.',
}
: {
thanks: 'Obrigado pelo feedback!',
skip: 'Pular',
multiple: (min: number, max: number) => `Escolha de ${min} a ${max} opções e pressione Enter para confirmar. `,
optional: 'Opcional · comece a digitar normalmente para continuar o trabalho.',
}
const isValidDigit = useCallback((digit: string): digit is string => {
if (!question || !/^\d$/.test(digit)) return false
return digit === '0' || question.options.some(option => option.position === Number(digit))
}, [question])

useDebouncedDigitInput({ inputValue, setInputValue, isValidDigit, onDigit: handleDigit, enabled: Boolean(question) })

if (thanks) return <Box marginTop={1}><Text color="success">✓ {copy.thanks}</Text></Box>
if (!offer || !question) return null

return (
<Box flexDirection="column" marginTop={1}>
<Box><Text color="ansi:cyan">● </Text><Text bold>{offer.title}</Text></Box>
{offer.intro ? <Text dimColor>{offer.intro}</Text> : null}
<Box marginTop={1}><Text>{question.text} <Text dimColor>({questionIndex + 1}/{offer.questions.length})</Text></Text></Box>
<Box flexDirection="column" marginLeft={2}>
{question.options.map(option => {
const selected = selectedOptionIds.has(option.id)
return <Text key={option.id}><Text color="ansi:cyan">[{option.position}]</Text> {question.type === 'multiple_choice' ? <Text color={selected ? 'success' : undefined}>{selected ? '◉' : '○'} </Text> : null}{option.label}</Text>
})}
<Text><Text color="ansi:cyan">[0]</Text> {copy.skip}</Text>
</Box>
<Text dimColor>{question.type === 'multiple_choice' ? copy.multiple(question.minSelections, question.maxSelections) : ''}{copy.optional}</Text>
</Box>
)
}
38 changes: 38 additions & 0 deletions src/components/VerbooFeedback/useVerbooStartupFeedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, test } from 'bun:test'

import { isStartupFeedbackEligible, type StartupFeedbackEligibility } from './useVerbooStartupFeedback.js'

const eligible: StartupFeedbackEligibility = {
enabled: true,
stdinTTY: true,
stdoutTTY: true,
entrypoint: 'cli',
isRemoteSession: false,
hasInitialPrompt: false,
isBare: false,
isCI: false,
telemetryDisabled: false,
policyAllowed: true,
inheritedSurveyDisabled: false,
}

describe('startup feedback eligibility', () => {
test('allows only a normal interactive CLI startup', () => {
expect(isStartupFeedbackEligible(eligible)).toBe(true)
})

test.each([
['stdinTTY', false],
['stdoutTTY', false],
['entrypoint', 'sdk-cli'],
['isRemoteSession', true],
['hasInitialPrompt', true],
['isBare', true],
['isCI', true],
['telemetryDisabled', true],
['policyAllowed', false],
['inheritedSurveyDisabled', true],
] as const)('rejects %s=%s', (key, value) => {
expect(isStartupFeedbackEligible({ ...eligible, [key]: value })).toBe(false)
})
})
245 changes: 245 additions & 0 deletions src/components/VerbooFeedback/useVerbooStartupFeedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'

import {
fetchNextVerbooFeedback,
finalizeVerbooFeedback,
flushVerbooFeedbackOutbox,
hasVerbooFeedbackReceipt,
markVerbooFeedbackViewed,
type VerbooFeedbackAnswer,
type VerbooFeedbackOffer,
} from '../../services/api/verbooFeedback.js'
import { isPolicyAllowed, waitForPolicyLimitsToLoad } from '../../services/policyLimits/index.js'
import { isBareMode, isEnvTruthy } from '../../utils/envUtils.js'
import { isTelemetryDisabled } from '../../utils/privacyLevel.js'

export type StartupFeedbackEligibility = {
enabled: boolean
stdinTTY: boolean
stdoutTTY: boolean
entrypoint?: string
isRemoteSession: boolean
hasInitialPrompt: boolean
isBare: boolean
isCI: boolean
telemetryDisabled: boolean
policyAllowed: boolean
inheritedSurveyDisabled: boolean
}

export function isStartupFeedbackEligible(options: StartupFeedbackEligibility): boolean {
return options.enabled && options.stdinTTY && options.stdoutTTY && options.entrypoint === 'cli' && !options.isRemoteSession && !options.hasInitialPrompt && !options.isBare && !options.isCI && !options.telemetryDisabled && options.policyAllowed && !options.inheritedSurveyDisabled
}

type Props = {
enabled: boolean
isRemoteSession: boolean
hasInitialPrompt: boolean
inputValue: string
setInputValue: (value: string) => void
submitCount: number
locale: 'pt' | 'en'
modelId: string
provider: string
}

export type VerbooStartupFeedbackState = {
offer: VerbooFeedbackOffer | null
questionIndex: number
selectedOptionIds: ReadonlySet<string>
thanks: boolean
handleDigit: (digit: string) => void
handleSubmit: (input: string) => boolean
dismissForWork: () => void
}

export function useVerbooStartupFeedback({
enabled,
isRemoteSession,
hasInitialPrompt,
inputValue,
setInputValue,
submitCount,
locale,
modelId,
provider,
}: Props): VerbooStartupFeedbackState {
const [offer, setOffer] = useState<VerbooFeedbackOffer | null>(null)
const [questionIndex, setQuestionIndex] = useState(0)
const [answers, setAnswers] = useState<VerbooFeedbackAnswer[]>([])
const [selectedOptionIds, setSelectedOptionIds] = useState<ReadonlySet<string>>(new Set())
const [thanks, setThanks] = useState(false)
const initialInput = useRef(inputValue)
const initialSubmitCount = useRef(submitCount)
const inputRef = useRef(inputValue)
const submitCountRef = useRef(submitCount)
const offerRef = useRef<VerbooFeedbackOffer | null>(null)
const userActedRef = useRef(inputValue !== '' || submitCount > 0)
const finalizingRef = useRef(false)
const requestStartedRef = useRef(false)
inputRef.current = inputValue
submitCountRef.current = submitCount
offerRef.current = offer

const eligibleAtMount = useRef(isStartupFeedbackEligible({
enabled,
stdinTTY: process.stdin.isTTY === true,
stdoutTTY: process.stdout.isTTY === true,
entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT,
isRemoteSession,
hasInitialPrompt,
isBare: isBareMode(),
isCI: isEnvTruthy(process.env.CI),
telemetryDisabled: isTelemetryDisabled(),
policyAllowed: isPolicyAllowed('allow_product_feedback'),
inheritedSurveyDisabled: isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY),
})).current

useEffect(() => {
if (!eligibleAtMount || requestStartedRef.current) return
requestStartedRef.current = true
let cancelled = false
void (async () => {
await waitForPolicyLimitsToLoad()
if (cancelled || isTelemetryDisabled() || !isPolicyAllowed('allow_product_feedback')) return
await flushVerbooFeedbackOutbox()
if (cancelled) return
try {
const next = await fetchNextVerbooFeedback({
locale,
modelId,
provider: provider.toLowerCase(),
cliVersion: MACRO.VERSION,
platform: process.platform,
architecture: process.arch,
})
if (!next || cancelled) return
if (hasVerbooFeedbackReceipt(next.campaignId)) {
void finalizeVerbooFeedback(next.campaignId, next.deliveryId, 'skip')
return
}
if (userActedRef.current || inputRef.current !== initialInput.current || submitCountRef.current !== initialSubmitCount.current) {
void finalizeVerbooFeedback(next.campaignId, next.deliveryId, 'skip')
return
}
setOffer(next)
offerRef.current = next
void markVerbooFeedbackViewed(next.deliveryId)
} catch {
// Feedback is optional. Startup and normal work continue silently.
}
})()
return () => { cancelled = true }
}, [eligibleAtMount, locale, modelId, provider])

const skip = useCallback(() => {
const current = offerRef.current
if (!current || finalizingRef.current) return
finalizingRef.current = true
setOffer(null)
offerRef.current = null
void finalizeVerbooFeedback(current.campaignId, current.deliveryId, 'skip')
}, [])

const dismissForWork = useCallback(() => {
userActedRef.current = true
skip()
}, [skip])

const finishQuestion = useCallback((optionIds: string[]) => {
const currentOffer = offerRef.current
if (!currentOffer || finalizingRef.current) return
const question = currentOffer.questions[questionIndex]
if (!question) return
const nextAnswers = [...answers, { questionId: question.id, optionIds }]
if (questionIndex < currentOffer.questions.length - 1) {
setAnswers(nextAnswers)
setQuestionIndex(index => index + 1)
setSelectedOptionIds(new Set())
return
}
finalizingRef.current = true
setOffer(null)
offerRef.current = null
setThanks(true)
void finalizeVerbooFeedback(currentOffer.campaignId, currentOffer.deliveryId, 'response', nextAnswers)
}, [answers, questionIndex])

const handleDigit = useCallback((digit: string) => {
const currentOffer = offerRef.current
const question = currentOffer?.questions[questionIndex]
if (!currentOffer || !question || finalizingRef.current) return
if (digit === '0') {
skip()
return
}
const position = Number(digit)
const option = question.options.find(item => item.position === position)
if (!option) return
if (question.type === 'single_choice') {
finishQuestion([option.id])
return
}
setSelectedOptionIds(current => {
const next = new Set(current)
if (next.has(option.id)) next.delete(option.id)
else if (next.size < question.maxSelections) next.add(option.id)
return next
})
}, [finishQuestion, questionIndex, skip])

const question = offer?.questions[questionIndex]
const validDigit = useCallback((input: string) => {
if (!question || !/^\d$/.test(input)) return false
if (input === '0') return true
return question.options.some(option => option.position === Number(input))
}, [question])

useEffect(() => {
if (submitCount === initialSubmitCount.current) return
userActedRef.current = true
if (offerRef.current) dismissForWork()
}, [dismissForWork, submitCount])

useEffect(() => {
if (inputValue === initialInput.current || inputValue === '') return
if (offerRef.current && validDigit(inputValue)) return
userActedRef.current = true
if (offerRef.current) dismissForWork()
}, [dismissForWork, inputValue, validDigit])

useEffect(() => {
if (!thanks) return
const timer = setTimeout(setThanks, 2_000, false)
return () => clearTimeout(timer)
}, [thanks])

const handleSubmit = useCallback((input: string): boolean => {
const currentOffer = offerRef.current
const currentQuestion = currentOffer?.questions[questionIndex]
if (!currentOffer || !currentQuestion) return false
const trimmed = input.trim()
if (validDigit(trimmed)) {
setInputValue('')
handleDigit(trimmed)
return true
}
if (currentQuestion.type === 'multiple_choice' && trimmed === '') {
if (selectedOptionIds.size >= currentQuestion.minSelections && selectedOptionIds.size <= currentQuestion.maxSelections) {
finishQuestion([...selectedOptionIds])
return true
}
// With no survey selection, Enter belongs to the normal prompt (for
// example accepting a suggestion). An incomplete selection stays open.
if (selectedOptionIds.size === 0) {
dismissForWork()
return false
}
return true
}
dismissForWork()
return false
}, [dismissForWork, finishQuestion, handleDigit, questionIndex, selectedOptionIds, setInputValue, validDigit])

return useMemo(() => ({ offer, questionIndex, selectedOptionIds, thanks, handleDigit, handleSubmit, dismissForWork }), [dismissForWork, handleDigit, handleSubmit, offer, questionIndex, selectedOptionIds, thanks])
}
Loading
Loading