Skip to content

Commit a2d2cb2

Browse files
committed
fix(cli): fix paste handling in ask_user custom input with prop-driven focus
- Add prop-driven focus to MultilineInput: useEffect calls focus/blur when focused prop changes - Simplify CustomAnswerInput: remove forwardRef/useImperativeHandle, let MultilineInput handle focus internally - Clean up AccordionQuestion: remove customInputRef and focus useEffect - Fix createTextPasteHandler to prioritize bracketed paste text over clipboard - Delete low-value mock-based test file The root cause was that OpenTUI routes paste events to the focused scrollbox, but nothing was explicitly focusing the custom input when it became active. This refactor moves focus management into MultilineInput itself, making it react to the focused prop rather than requiring imperative ref-based focus.
1 parent d990ed5 commit a2d2cb2

File tree

2 files changed

+43
-24
lines changed

2 files changed

+43
-24
lines changed

cli/src/components/multiline-input.tsx

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ function isAltModifier(key: KeyEvent): boolean {
142142
)
143143
}
144144

145+
// Helper type for scrollbox with focus/blur methods (not exposed in OpenTUI types but available at runtime)
146+
interface FocusableScrollBox {
147+
focus?: () => void
148+
blur?: () => void
149+
}
150+
145151
interface MultilineInputProps {
146152
value: string
147153
onChange: (value: InputValue) => void
@@ -158,6 +164,7 @@ interface MultilineInputProps {
158164

159165
export type MultilineInputHandle = {
160166
focus: () => void
167+
blur: () => void
161168
}
162169

163170
export const MultilineInput = forwardRef<
@@ -224,14 +231,26 @@ export const MultilineInput = forwardRef<
224231
).lineInfo
225232
: null
226233

234+
// Focus/blur scrollbox when focused prop changes
235+
const prevFocusedRef = useRef(false)
236+
useEffect(() => {
237+
if (focused && !prevFocusedRef.current) {
238+
(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.()
239+
} else if (!focused && prevFocusedRef.current) {
240+
(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.()
241+
}
242+
prevFocusedRef.current = focused
243+
}, [focused])
244+
245+
// Expose focus/blur for imperative use cases
227246
useImperativeHandle(
228247
forwardedRef,
229248
() => ({
230249
focus: () => {
231-
const node = scrollBoxRef.current
232-
if (node && typeof (node as any).focus === 'function') {
233-
;(node as any).focus()
234-
}
250+
(scrollBoxRef.current as FocusableScrollBox | null)?.focus?.()
251+
},
252+
blur: () => {
253+
(scrollBoxRef.current as FocusableScrollBox | null)?.blur?.()
235254
},
236255
}),
237256
[],

cli/src/utils/strings.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ export function createTextPasteHandler(
6363
text: string,
6464
cursorPosition: number,
6565
onChange: (value: InputValue) => void,
66-
): (fallbackText?: string) => void {
67-
return (fallbackText) => {
68-
const pasteText = readClipboardText() ?? fallbackText
66+
): (eventText?: string) => void {
67+
return (eventText) => {
68+
const pasteText = eventText || readClipboardText()
6969
if (!pasteText) return
7070
const { newText, newCursor } = insertTextAtCursor(
7171
text,
@@ -83,12 +83,12 @@ export function createTextPasteHandler(
8383
/**
8484
* Creates a paste handler that supports both image and text paste.
8585
*
86-
* When fallbackText is provided (from drag-drop or native paste event),
87-
* it takes FULL priority over the clipboard. This is because:
86+
* When eventText is provided (from drag-drop or native paste event),
87+
* it takes priority over the clipboard. This is because:
8888
* - Drag operations provide file paths directly without updating the clipboard
8989
* - The clipboard might contain stale data from a previous copy operation
9090
*
91-
* Only when NO fallbackText is provided do we read from the clipboard.
91+
* Only when NO eventText is provided do we read from the clipboard.
9292
*/
9393
export function createPasteHandler(options: {
9494
text: string
@@ -98,7 +98,7 @@ export function createPasteHandler(options: {
9898
onPasteImagePath?: (imagePath: string) => void
9999
onPasteLongText?: (text: string) => void
100100
cwd?: string
101-
}): (fallbackText?: string) => void {
101+
}): (eventText?: string) => void {
102102
const {
103103
text,
104104
cursorPosition,
@@ -108,54 +108,54 @@ export function createPasteHandler(options: {
108108
onPasteLongText,
109109
cwd,
110110
} = options
111-
return (fallbackText) => {
111+
return (eventText) => {
112112
// If we have direct input text from the paste event (e.g., from terminal paste),
113113
// check if it looks like an image filename and if we can get the full path from clipboard
114-
if (fallbackText && onPasteImagePath) {
114+
if (eventText && onPasteImagePath) {
115115
// The terminal often only passes the filename when pasting a file copied from Finder.
116116
// Check if this looks like just a filename (no path separators) that's an image.
117117
const looksLikeImageFilename =
118-
isImageFile(fallbackText) &&
119-
!fallbackText.includes('/') &&
120-
!fallbackText.includes('\\')
118+
isImageFile(eventText) &&
119+
!eventText.includes('/') &&
120+
!eventText.includes('\\')
121121

122122
if (looksLikeImageFilename) {
123123
// Try to get the full path from the clipboard's file URL
124124
const clipboardFilePath = readClipboardImageFilePath()
125125
// Verify the clipboard path's basename matches exactly (not just endsWith)
126126
if (
127127
clipboardFilePath &&
128-
path.basename(clipboardFilePath) === fallbackText
128+
path.basename(clipboardFilePath) === eventText
129129
) {
130130
// The clipboard has the full path to the same file - use it!
131131
onPasteImagePath(clipboardFilePath)
132132
return
133133
}
134134
}
135135

136-
// Check if fallbackText is a full path to an image file
136+
// Check if eventText is a full path to an image file
137137
if (cwd) {
138-
const imagePath = getImageFilePathFromText(fallbackText, cwd)
138+
const imagePath = getImageFilePathFromText(eventText, cwd)
139139
if (imagePath) {
140140
onPasteImagePath(imagePath)
141141
return
142142
}
143143
}
144144
}
145145

146-
// fallbackText provided but not an image - check if it's long text
147-
if (fallbackText) {
146+
// eventText provided but not an image - check if it's long text
147+
if (eventText) {
148148
// If text is long, treat it as an attachment
149-
if (onPasteLongText && fallbackText.length > LONG_TEXT_THRESHOLD) {
150-
onPasteLongText(fallbackText)
149+
if (onPasteLongText && eventText.length > LONG_TEXT_THRESHOLD) {
150+
onPasteLongText(eventText)
151151
return
152152
}
153153

154154
// Otherwise paste it as regular text
155155
const { newText, newCursor } = insertTextAtCursor(
156156
text,
157157
cursorPosition,
158-
fallbackText,
158+
eventText,
159159
)
160160
onChange({
161161
text: newText,

0 commit comments

Comments
 (0)