Skip to content

Commit fe0bd70

Browse files
committed
refactor(cli): use homegrown clipboard reading for paste handling
- MultilineInput now forwards paste events to parent via onPaste callback - Added createTextPasteHandler and createPasteHandler utilities in strings.ts - Main chat uses createPasteHandler (with image support) - Feedback and ask-user inputs use createTextPasteHandler (text only) - All paste logic uses homegrown readClipboardText() with OpenTUI fallback
1 parent 8317b58 commit fe0bd70

File tree

7 files changed

+110
-57
lines changed

7 files changed

+110
-57
lines changed

cli/src/chat.tsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import { addClipboardPlaceholder, addPendingImageFromFile } from './utils/add-pe
4949
import { createChatScrollAcceleration } from './utils/chat-scroll-accel'
5050
import { showClipboardMessage } from './utils/clipboard'
5151
import { readClipboardImage } from './utils/clipboard-image'
52+
import { createPasteHandler } from './utils/strings'
5253
import { getInputModeConfig } from './utils/input-modes'
5354
import {
5455
type ChatKeyboardState,
@@ -716,7 +717,16 @@ export const Chat = ({
716717
handleExitFeedback()
717718
}, [closeFeedback, handleExitFeedback])
718719

719-
720+
// Ensure bracketed paste events target the active chat input
721+
useEffect(() => {
722+
if (feedbackMode) {
723+
inputRef.current?.focus()
724+
return
725+
}
726+
if (!askUserState) {
727+
inputRef.current?.focus()
728+
}
729+
}, [feedbackMode, askUserState, inputRef])
720730

721731
const handleSubmit = useCallback(async () => {
722732
ensureQueueActiveBeforeSubmit()
@@ -1225,6 +1235,12 @@ export const Chat = ({
12251235
feedbackMode={feedbackMode}
12261236
handleExitFeedback={handleExitFeedback}
12271237
handleSubmit={handleSubmit}
1238+
onPaste={createPasteHandler({
1239+
text: inputValue,
1240+
cursorPosition,
1241+
onChange: setInputValue,
1242+
onPasteImage: chatKeyboardHandlers.onPasteImage,
1243+
})}
12281244
/>
12291245
</box>
12301246
</box>

cli/src/components/ask-user/components/other-text-input.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useTheme } from '../../../hooks/use-theme'
99
import { Button } from '../../button'
1010
import { MultilineInput } from '../../multiline-input'
1111
import { SYMBOLS } from '../constants'
12+
import { createTextPasteHandler } from '../../../utils/strings'
1213

1314
import type { InputValue } from '../../../state/chat-store'
1415

@@ -100,6 +101,7 @@ export const OtherTextInput: React.FC<OtherTextInputProps> = ({
100101
onChange={onChange}
101102
onSubmit={onSubmit}
102103
onKeyIntercept={handleKeyIntercept}
104+
onPaste={createTextPasteHandler(text, cursorPosition, onChange)}
103105
placeholder={placeholder}
104106
focused={isFocused}
105107
maxHeight={3}

cli/src/components/chat-input-bar.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,11 @@ interface ChatInputBarProps {
5757

5858
// Feedback mode
5959
feedbackMode: boolean
60-
handleExitFeedback: () => void // Handlers
60+
handleExitFeedback: () => void
61+
62+
// Handlers
6163
handleSubmit: () => Promise<void>
64+
onPaste: (fallbackText?: string) => void
6265
}
6366

6467
export const ChatInputBar = ({
@@ -91,6 +94,7 @@ export const ChatInputBar = ({
9194
feedbackMode,
9295
handleExitFeedback,
9396
handleSubmit,
97+
onPaste,
9498
}: ChatInputBarProps) => {
9599
const inputMode = useChatStore((state) => state.inputMode)
96100
const setInputMode = useChatStore((state) => state.setInputMode)
@@ -324,6 +328,7 @@ export const ChatInputBar = ({
324328
value={inputValue}
325329
onChange={handleInputChange}
326330
onSubmit={handleSubmit}
331+
onPaste={onPaste}
327332
onKeyIntercept={handleKeyIntercept}
328333
placeholder={effectivePlaceholder}
329334
focused={inputFocused && !feedbackMode}
@@ -406,6 +411,7 @@ export const ChatInputBar = ({
406411
value={inputValue}
407412
onChange={handleInputChange}
408413
onSubmit={handleSubmit}
414+
onPaste={onPaste}
409415
onKeyIntercept={handleKeyIntercept}
410416
placeholder={effectivePlaceholder}
411417
focused={inputFocused && !feedbackMode}

cli/src/components/feedback-container.tsx

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2-
import React, { useCallback, useEffect, useRef } from 'react'
2+
import React, { useCallback, useEffect } from 'react'
33
import { useShallow } from 'zustand/react/shallow'
44

55
import { FeedbackInputMode } from './feedback-input-mode'
@@ -62,8 +62,6 @@ export const FeedbackContainer: React.FC<FeedbackContainerProps> = ({
6262
})),
6363
)
6464

65-
const previousFeedbackModeRef = useRef(feedbackMode)
66-
6765
const buildMessageContext = useCallback(
6866
(targetMessageId: string | null) => {
6967
const target = targetMessageId
@@ -154,11 +152,8 @@ export const FeedbackContainer: React.FC<FeedbackContainerProps> = ({
154152
}, [closeFeedback, onExitFeedback])
155153

156154
useEffect(() => {
157-
if (feedbackMode !== previousFeedbackModeRef.current) {
158-
previousFeedbackModeRef.current = feedbackMode
159-
if (inputRef.current) {
160-
inputRef.current.focus()
161-
}
155+
if (feedbackMode && inputRef.current) {
156+
inputRef.current.focus()
162157
}
163158
}, [feedbackMode, inputRef])
164159

cli/src/components/feedback-input-mode.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Separator } from './separator'
77
import { useTheme } from '../hooks/use-theme'
88
import { useChatStore } from '../state/chat-store'
99
import { BORDER_CHARS } from '../utils/ui-constants'
10+
import { createTextPasteHandler } from '../utils/strings'
1011

1112
type CategoryHighlightKey = 'success' | 'error' | 'warning' | 'info'
1213

@@ -114,6 +115,10 @@ const FeedbackTextSection: React.FC<FeedbackTextSectionProps> = ({
114115
onCursorChange(cursor + 1)
115116
return true
116117
}}
118+
onPaste={createTextPasteHandler(value, cursor, ({ text, cursorPosition }) => {
119+
onChange(text)
120+
onCursorChange(cursorPosition)
121+
})}
117122
placeholder={placeholder}
118123
focused={inputFocused}
119124
maxHeight={5}

cli/src/components/multiline-input.tsx

Lines changed: 13 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ import { calculateNewCursorPosition } from '../utils/word-wrap-utils'
1919
import type { InputValue } from '../state/chat-store'
2020
import type {
2121
KeyEvent,
22-
PasteEvent,
2322
ScrollBoxRenderable,
2423
TextBufferView,
2524
TextRenderable,
@@ -94,6 +93,7 @@ interface MultilineInputProps {
9493
onChange: (value: InputValue) => void
9594
onSubmit: () => void
9695
onKeyIntercept?: (key: KeyEvent) => boolean
96+
onPaste: (fallbackText?: string) => void
9797
placeholder?: string
9898
focused?: boolean
9999
shouldBlinkCursor?: boolean
@@ -115,6 +115,7 @@ export const MultilineInput = forwardRef<
115115
value,
116116
onChange,
117117
onSubmit,
118+
onPaste,
118119
placeholder = '',
119120
focused = true,
120121
shouldBlinkCursor,
@@ -134,9 +135,6 @@ export const MultilineInput = forwardRef<
134135
const [measuredCols, setMeasuredCols] = useState<number | null>(null)
135136
const [lastActivity, setLastActivity] = useState(Date.now())
136137

137-
// Refs to track latest values for paste handler (prevents stale closure issues)
138-
const valueRef = useRef(value)
139-
const cursorPositionRef = useRef(cursorPosition)
140138
const stickyColumnRef = useRef<number | null>(null)
141139

142140
// Helper to get or set the sticky column for vertical navigation
@@ -146,27 +144,19 @@ export const MultilineInput = forwardRef<
146144
return stickyColumnRef.current
147145
}
148146
const lineIndex = lineStarts.findLastIndex(
149-
(lineStart) => lineStart <= cursorPositionRef.current,
147+
(lineStart) => lineStart <= cursorPosition,
150148
)
151149
// Account for cursorIsChar offset like cursorDown does
152150
const column =
153151
lineIndex === -1
154152
? 0
155-
: cursorPositionRef.current -
156-
lineStarts[lineIndex] +
157-
(cursorIsChar ? -1 : 0)
153+
: cursorPosition - lineStarts[lineIndex] + (cursorIsChar ? -1 : 0)
158154
stickyColumnRef.current = Math.max(0, column)
159155
return stickyColumnRef.current
160156
},
161157
[],
162158
)
163159

164-
// Keep refs in sync with props
165-
useEffect(() => {
166-
valueRef.current = value
167-
cursorPositionRef.current = cursorPosition
168-
}, [value, cursorPosition])
169-
170160
// Update last activity on value or cursor changes
171161
useEffect(() => {
172162
setLastActivity(Date.now())
@@ -194,35 +184,6 @@ export const MultilineInput = forwardRef<
194184
[],
195185
)
196186

197-
const handlePaste = useCallback(
198-
(event: PasteEvent) => {
199-
if (!focused) return
200-
201-
const text = event.text ?? ''
202-
if (!text) return
203-
204-
// Use refs to get the latest values, avoiding stale closure issues
205-
// when multiple paste events fire rapidly before React re-renders
206-
const currentValue = valueRef.current
207-
const currentCursor = cursorPositionRef.current
208-
209-
const newValue =
210-
currentValue.slice(0, currentCursor) + text + currentValue.slice(currentCursor)
211-
const newCursor = currentCursor + text.length
212-
213-
// Update refs immediately so subsequent rapid events see the new state
214-
valueRef.current = newValue
215-
cursorPositionRef.current = newCursor
216-
217-
onChange({
218-
text: newValue,
219-
cursorPosition: newCursor,
220-
lastEditDueToNav: false,
221-
})
222-
},
223-
[focused, onChange],
224-
)
225-
226187
const cursorRow = lineInfo
227188
? Math.max(
228189
0,
@@ -727,7 +688,10 @@ export const MultilineInput = forwardRef<
727688
preventKeyDefault(key)
728689

729690
const lineStarts = lineInfo?.lineStarts ?? []
730-
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
691+
const desiredIndex = getOrSetStickyColumn(
692+
lineStarts,
693+
!shouldHighlight,
694+
)
731695

732696
onChange({
733697
text: value,
@@ -746,7 +710,10 @@ export const MultilineInput = forwardRef<
746710
// Down arrow (no modifiers)
747711
if (key.name === 'down' && !key.ctrl && !key.meta && !key.option) {
748712
const lineStarts = lineInfo?.lineStarts ?? []
749-
const desiredIndex = getOrSetStickyColumn(lineStarts, !shouldHighlight)
713+
const desiredIndex = getOrSetStickyColumn(
714+
lineStarts,
715+
!shouldHighlight,
716+
)
750717

751718
onChange({
752719
text: value,
@@ -844,7 +811,7 @@ export const MultilineInput = forwardRef<
844811
stickyScroll={true}
845812
stickyStart="bottom"
846813
scrollbarOptions={{ visible: false }}
847-
onPaste={handlePaste}
814+
onPaste={(event) => onPaste(event.text)}
848815
style={{
849816
flexGrow: 0,
850817
flexShrink: 0,

cli/src/utils/strings.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { hasClipboardImage, readClipboardText } from './clipboard-image'
2+
import type { InputValue } from '../state/chat-store'
3+
14
export function getSubsequenceIndices(
25
str: string,
36
sub: string,
@@ -22,4 +25,63 @@ export function getSubsequenceIndices(
2225
return null
2326
}
2427

25-
export const BULLET_CHAR = '• '
28+
export const BULLET_CHAR = '• '
29+
30+
/**
31+
* Insert text at cursor position and return the new text and cursor position.
32+
*/
33+
function insertTextAtCursor(
34+
text: string,
35+
cursorPosition: number,
36+
textToInsert: string,
37+
): { newText: string; newCursor: number } {
38+
const before = text.slice(0, cursorPosition)
39+
const after = text.slice(cursorPosition)
40+
return {
41+
newText: before + textToInsert + after,
42+
newCursor: before.length + textToInsert.length,
43+
}
44+
}
45+
46+
/**
47+
* Creates a paste handler for text-only inputs (feedback, ask-user, etc.).
48+
* Reads from clipboard with OpenTUI fallback, then inserts at cursor.
49+
*/
50+
export function createTextPasteHandler(
51+
text: string,
52+
cursorPosition: number,
53+
onChange: (value: InputValue) => void,
54+
): (fallbackText?: string) => void {
55+
return (fallbackText) => {
56+
const pasteText = readClipboardText() ?? fallbackText
57+
if (!pasteText) return
58+
const { newText, newCursor } = insertTextAtCursor(text, cursorPosition, pasteText)
59+
onChange({ text: newText, cursorPosition: newCursor, lastEditDueToNav: false })
60+
}
61+
}
62+
63+
/**
64+
* Creates a paste handler that supports both image and text paste.
65+
* Checks for image first, falls back to text paste.
66+
*/
67+
export function createPasteHandler(options: {
68+
text: string
69+
cursorPosition: number
70+
onChange: (value: InputValue) => void
71+
onPasteImage?: () => void
72+
}): (fallbackText?: string) => void {
73+
const { text, cursorPosition, onChange, onPasteImage } = options
74+
return (fallbackText) => {
75+
// Check for image first if handler provided
76+
if (onPasteImage && hasClipboardImage()) {
77+
onPasteImage()
78+
return
79+
}
80+
// Handle text paste
81+
const pasteText = readClipboardText() ?? fallbackText
82+
if (pasteText) {
83+
const { newText, newCursor } = insertTextAtCursor(text, cursorPosition, pasteText)
84+
onChange({ text: newText, cursorPosition: newCursor, lastEditDueToNav: false })
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)