Skip to content

Commit e3b4eb2

Browse files
committed
feat(cli): improve ask_user custom input UX and filtering
- Add light border around custom input to differentiate from options - Add conditional scrollbar that only shows when content exceeds max height - Preserve custom text when switching between options - Fix click behavior: first click exits custom mode and highlights option - Filter out redundant Custom/Other options from agent responses - Update tool description to discourage agents from including Custom options
1 parent 0520723 commit e3b4eb2

File tree

7 files changed

+108
-7
lines changed

7 files changed

+108
-7
lines changed

cli/src/components/ask-user/components/accordion-question.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,11 +69,12 @@ export const AccordionQuestion: React.FC<AccordionQuestionProps> = ({
6969
if (!answer) return '(click to answer)'
7070

7171
if (answer.isCustom && answer.customText) {
72+
const hadNewlines = /\r?\n/.test(answer.customText)
7273
const flattenedText = answer.customText
7374
.replace(/\r?\n/g, ' ')
7475
.replace(/\s+/g, ' ')
7576
.trim()
76-
return `Custom: ${flattenedText}`
77+
return `Custom: ${flattenedText}${hadNewlines ? '…' : ''}`
7778
}
7879

7980
if (isMultiSelect && answer.selectedIndices) {

cli/src/components/ask-user/components/custom-answer-input.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import React, { memo } from 'react'
66

7+
import { useTheme } from '../../../hooks/use-theme'
78
import { MultilineInput } from '../../multiline-input'
89

910
export interface CustomAnswerInputProps {
@@ -26,8 +27,18 @@ export const CustomAnswerInput: React.FC<CustomAnswerInputProps> = memo(
2627
onSubmit,
2728
onPaste,
2829
}) => {
30+
const theme = useTheme()
31+
2932
return (
3033
<box style={{ flexDirection: 'column', paddingLeft: optionIndent + 2 }}>
34+
<box
35+
style={{
36+
borderStyle: 'single',
37+
borderColor: theme.muted,
38+
paddingLeft: 1,
39+
paddingRight: 1,
40+
}}
41+
>
3142
<MultilineInput
3243
value={value}
3344
cursorPosition={cursorPosition}
@@ -44,7 +55,9 @@ export const CustomAnswerInput: React.FC<CustomAnswerInputProps> = memo(
4455
maxHeight={5}
4556
minHeight={1}
4657
placeholder="Type your answer..."
58+
showScrollbar={true}
4759
/>
60+
</box>
4861
</box>
4962
)
5063
},

cli/src/components/ask-user/components/question-header.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,13 @@
66
import { TextAttributes } from '@opentui/core'
77
import React, { memo } from 'react'
88

9+
import { useTerminalLayout } from '../../../hooks/use-terminal-layout'
910
import { useTheme } from '../../../hooks/use-theme'
1011
import { Button } from '../../button'
1112

13+
// Overhead for the answer line: modal borders (~6) + marginLeft (3) + "↳ " (3) + quotes (2) + buffer (6)
14+
const ANSWER_LINE_OVERHEAD = 20
15+
1216
export interface QuestionHeaderProps {
1317
questionText: string
1418
questionPrefix: string
@@ -28,6 +32,14 @@ export const QuestionHeader: React.FC<QuestionHeaderProps> = memo(
2832
onToggleExpand,
2933
}) => {
3034
const theme = useTheme()
35+
const { terminalWidth } = useTerminalLayout()
36+
37+
// Calculate available width for the answer text and truncate with ellipsis at end
38+
const availableWidth = Math.max(20, terminalWidth - ANSWER_LINE_OVERHEAD)
39+
const truncatedAnswer =
40+
answerDisplay.length > availableWidth
41+
? answerDisplay.slice(0, availableWidth - 1) + '…'
42+
: answerDisplay
3143

3244
return (
3345
<Button
@@ -50,13 +62,13 @@ export const QuestionHeader: React.FC<QuestionHeaderProps> = memo(
5062
</text>
5163
{/* Answer displayed on separate line when collapsed (like User Answers style) */}
5264
{!isExpanded && (
53-
<text truncate wrapMode="none" style={{ marginLeft: 3 }}>
65+
<text wrapMode="none" style={{ marginLeft: 3 }}>
5466
<span fg={theme.primary}></span>
5567
<span
5668
fg={isAnswered ? theme.primary : theme.muted}
5769
attributes={TextAttributes.ITALIC}
5870
>
59-
{isAnswered ? `"${answerDisplay}"` : '(click to answer)'}
71+
{isAnswered ? `"${truncatedAnswer}"` : '(click to answer)'}
6072
</span>
6173
</text>
6274
)}

cli/src/components/ask-user/index.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
159159
setSubmitFocused(false)
160160
const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX
161161

162+
// When clicking out of Custom typing mode, first click just exits and highlights
163+
// the option without selecting it (requires a second click to actually select)
164+
if (source === 'mouse' && isTypingCustom && !isCustomOption) {
165+
setIsTypingCustom(false)
166+
setFocusedOptionIndex(optionIndex)
167+
setShowFocusHighlight(true)
168+
// Deselect Custom option but preserve the typed text
169+
setAnswerForQuestion(questionIndex, (currentAnswer) => ({
170+
...currentAnswer,
171+
isCustom: false,
172+
}))
173+
return
174+
}
175+
162176
if (source === 'mouse' && !isCustomOption) {
163177
setShowFocusHighlight(false)
164178
suppressNextHoverFocusRef.current = true
@@ -177,6 +191,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
177191
selectedIndex: optionIndex,
178192
selectedIndices: undefined,
179193
isCustom: false,
194+
customText: currentAnswer?.customText, // Preserve custom text when switching away
180195
},
181196
)
182197

@@ -197,7 +212,7 @@ export const MultipleChoiceForm: React.FC<MultipleChoiceFormProps> = ({
197212
setExpandedIndex(null)
198213
focusSubmit({ questionIndex, optionIndex })
199214
},
200-
[questions, openQuestion, focusSubmit, setAnswerForQuestion],
215+
[questions, openQuestion, focusSubmit, setAnswerForQuestion, isTypingCustom],
201216
)
202217

203218
// Handle toggling an option (multi-select)

cli/src/components/multiline-input.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ interface MultilineInputProps {
160160
maxHeight?: number
161161
minHeight?: number
162162
cursorPosition: number
163+
showScrollbar?: boolean
163164
}
164165

165166
export type MultilineInputHandle = {
@@ -183,6 +184,7 @@ export const MultilineInput = forwardRef<
183184
minHeight = 1,
184185
onKeyIntercept,
185186
cursorPosition,
187+
showScrollbar = false,
186188
}: MultilineInputProps,
187189
forwardedRef,
188190
) {
@@ -1034,9 +1036,13 @@ export const MultilineInput = forwardRef<
10341036

10351037
const heightLines = Math.max(effectiveMinHeight, rawHeight)
10361038

1039+
// Content is scrollable when total lines exceed max height
1040+
const isScrollable = totalLines > safeMaxHeight
1041+
10371042
return {
10381043
heightLines,
10391044
gutterEnabled,
1045+
isScrollable,
10401046
}
10411047
})()
10421048

@@ -1056,6 +1062,10 @@ export const MultilineInput = forwardRef<
10561062
stickyScroll={true}
10571063
stickyStart="bottom"
10581064
scrollbarOptions={{ visible: false }}
1065+
verticalScrollbarOptions={{
1066+
visible: showScrollbar && layoutMetrics.isScrollable,
1067+
trackOptions: { width: 1 },
1068+
}}
10591069
onPaste={(event) => onPaste(event.text)}
10601070
onMouseDown={handleMouseDown}
10611071
style={{

cli/src/hooks/use-ask-user-bridge.ts

Lines changed: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,66 @@ import { useEffect } from 'react'
33

44
import { useChatStore } from '../state/chat-store'
55

6+
import type { AskUserQuestion } from '../state/chat-store'
7+
8+
/**
9+
* Patterns that indicate a "custom" or "other" catch-all option.
10+
* These are redundant since the UI automatically provides a Custom text input.
11+
*/
12+
const REDUNDANT_OPTION_PATTERNS = [
13+
/^custom$/i,
14+
/^other$/i,
15+
/^none\s*(of\s*the\s*above)?$/i,
16+
/^something\s*else$/i,
17+
/^enter\s*(my\s*)?own$/i,
18+
/^type\s*(my\s*)?own$/i,
19+
/^write\s*(my\s*)?own$/i,
20+
]
21+
22+
/**
23+
* Gets the label from an option, handling both string and object formats.
24+
*/
25+
function getOptionLabel(option: string | { label: string; description?: string }): string {
26+
return typeof option === 'string' ? option : option.label
27+
}
28+
29+
/**
30+
* Checks if an option label matches any of the redundant "custom/other" patterns.
31+
*/
32+
function isRedundantOption(option: string | { label: string; description?: string }): boolean {
33+
const label = getOptionLabel(option).trim()
34+
return REDUNDANT_OPTION_PATTERNS.some((pattern) => pattern.test(label))
35+
}
36+
37+
/**
38+
* Filters out redundant "Custom"/"Other" options from questions.
39+
* The UI already provides a Custom text input, so these are unnecessary and confusing.
40+
*/
41+
function filterRedundantOptions(questions: AskUserQuestion[]): AskUserQuestion[] {
42+
return questions.map((question) => {
43+
const filteredOptions = question.options.filter((option) => !isRedundantOption(option))
44+
return {
45+
...question,
46+
// Preserve the original array type (string[] or object[])
47+
options: filteredOptions as typeof question.options,
48+
}
49+
})
50+
}
51+
652
export function useAskUserBridge() {
753
const setAskUserState = useChatStore((state) => state.setAskUserState)
854

955
useEffect(() => {
1056
const unsubscribe = AskUserBridge.subscribe((request) => {
1157
if (request) {
58+
// Filter out redundant "Custom"/"Other" options since UI provides its own
59+
const filteredQuestions = filterRedundantOptions(request.questions)
1260
setAskUserState({
1361
toolCallId: request.toolCallId,
14-
questions: request.questions,
62+
questions: filteredQuestions,
1563
// Initialize based on question type: multi-select → [], single-select → -1
16-
selectedAnswers: request.questions.map((q) => (q.multiSelect ? [] : -1)),
17-
otherTexts: new Array(request.questions.length).fill(''),
64+
selectedAnswers: filteredQuestions.map((q) => (q.multiSelect ? [] : -1)),
65+
otherTexts: new Array(filteredQuestions.length).fill(''),
1866
})
1967
} else {
2068
setAskUserState(null)

common/src/tools/params/tool/ask-user.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ The user can either:
110110
- Type a custom answer in the "Other" text field
111111
- Skip the questions to provide different instructions instead
112112
113+
IMPORTANT: Do NOT include options like "Custom", "Other", "None of the above", or similar catch-all options. The UI automatically provides a "Custom" text input field for users to type their own answer. Including such options would be redundant and confusing.
114+
113115
Single-select example:
114116
${$getNativeToolCallExampleString({
115117
toolName,

0 commit comments

Comments
 (0)