Skip to content

Commit 91f632e

Browse files
authored
fix: improve prompt history navigation to not interfere with text editing (#4677)
1 parent 4ccd6fa commit 91f632e

File tree

2 files changed

+89
-102
lines changed

2 files changed

+89
-102
lines changed

webview-ui/src/components/chat/ChatTextArea.tsx

Lines changed: 1 addition & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,7 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
159159
const [isFocused, setIsFocused] = useState(false)
160160

161161
// Use custom hook for prompt history navigation
162-
const {
163-
inputValueWithCursor,
164-
setInputValueWithCursor,
165-
handleHistoryNavigation,
166-
resetHistoryNavigation,
167-
resetOnInputChange,
168-
} = usePromptHistory({
162+
const { handleHistoryNavigation, resetHistoryNavigation, resetOnInputChange } = usePromptHistory({
169163
clineMessages,
170164
taskHistory,
171165
cwd,
@@ -466,27 +460,6 @@ const ChatTextArea = forwardRef<HTMLTextAreaElement, ChatTextAreaProps>(
466460
}
467461
}, [inputValue, intendedCursorPosition])
468462

469-
// Handle cursor positioning after history navigation
470-
useLayoutEffect(() => {
471-
if (!inputValueWithCursor.afterRender || !textAreaRef.current) return
472-
473-
if (inputValueWithCursor.afterRender === "SET_CURSOR_FIRST_LINE") {
474-
const firstLineEnd =
475-
inputValueWithCursor.value.indexOf("\n") === -1
476-
? inputValueWithCursor.value.length
477-
: inputValueWithCursor.value.indexOf("\n")
478-
textAreaRef.current.setSelectionRange(firstLineEnd, firstLineEnd)
479-
} else if (inputValueWithCursor.afterRender === "SET_CURSOR_LAST_LINE") {
480-
const lines = inputValueWithCursor.value.split("\n")
481-
const lastLineStart = inputValueWithCursor.value.length - lines[lines.length - 1].length
482-
textAreaRef.current.setSelectionRange(lastLineStart, lastLineStart)
483-
} else if (inputValueWithCursor.afterRender === "SET_CURSOR_START") {
484-
textAreaRef.current.setSelectionRange(0, 0)
485-
}
486-
487-
setInputValueWithCursor({ value: inputValueWithCursor.value })
488-
}, [inputValueWithCursor, setInputValueWithCursor])
489-
490463
// Ref to store the search timeout.
491464
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
492465

webview-ui/src/components/chat/hooks/usePromptHistory.ts

Lines changed: 88 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,12 @@ interface UsePromptHistoryProps {
99
setInputValue: (value: string) => void
1010
}
1111

12-
interface CursorPositionState {
13-
value: string
14-
afterRender?: "SET_CURSOR_FIRST_LINE" | "SET_CURSOR_LAST_LINE" | "SET_CURSOR_START"
15-
}
16-
1712
export interface UsePromptHistoryReturn {
1813
historyIndex: number
1914
setHistoryIndex: (index: number) => void
2015
tempInput: string
2116
setTempInput: (input: string) => void
2217
promptHistory: string[]
23-
inputValueWithCursor: CursorPositionState
24-
setInputValueWithCursor: (state: CursorPositionState) => void
2518
handleHistoryNavigation: (
2619
event: React.KeyboardEvent<HTMLTextAreaElement>,
2720
showContextMenu: boolean,
@@ -45,49 +38,35 @@ export const usePromptHistory = ({
4538
const [historyIndex, setHistoryIndex] = useState(-1)
4639
const [tempInput, setTempInput] = useState("")
4740
const [promptHistory, setPromptHistory] = useState<string[]>([])
48-
const [inputValueWithCursor, setInputValueWithCursor] = useState<CursorPositionState>({ value: inputValue })
4941

5042
// Initialize prompt history with hybrid approach: conversation messages if in task, otherwise task history
5143
const filteredPromptHistory = useMemo(() => {
5244
// First try to get conversation messages (user_feedback from clineMessages)
5345
const conversationPrompts = clineMessages
54-
?.filter((message) => {
55-
// Filter for user_feedback messages that have text content
56-
return (
57-
message.type === "say" &&
58-
message.say === "user_feedback" &&
59-
message.text &&
60-
message.text.trim() !== ""
61-
)
62-
})
46+
?.filter((message) => message.type === "say" && message.say === "user_feedback" && message.text?.trim())
6347
.map((message) => message.text!)
6448

6549
// If we have conversation messages, use those (newest first when navigating up)
66-
if (conversationPrompts && conversationPrompts.length > 0) {
67-
return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse() // newest first for conversation messages
50+
if (conversationPrompts?.length) {
51+
return conversationPrompts.slice(-MAX_PROMPT_HISTORY_SIZE).reverse()
6852
}
6953

7054
// If we have clineMessages array (meaning we're in an active task), don't fall back to task history
7155
// Only use task history when starting fresh (no active conversation)
72-
if (clineMessages && clineMessages.length > 0) {
56+
if (clineMessages?.length) {
7357
return []
7458
}
7559

7660
// Fall back to task history only when starting fresh (no active conversation)
77-
if (!taskHistory || taskHistory.length === 0 || !cwd) {
61+
if (!taskHistory?.length || !cwd) {
7862
return []
7963
}
8064

8165
// Extract user prompts from task history for the current workspace only
82-
const taskPrompts = taskHistory
83-
.filter((item) => {
84-
// Filter by workspace and ensure task is not empty
85-
return item.task && item.task.trim() !== "" && (!item.workspace || item.workspace === cwd)
86-
})
66+
return taskHistory
67+
.filter((item) => item.task?.trim() && (!item.workspace || item.workspace === cwd))
8768
.map((item) => item.task)
8869
.slice(0, MAX_PROMPT_HISTORY_SIZE)
89-
90-
return taskPrompts
9170
}, [clineMessages, taskHistory, cwd])
9271

9372
// Update prompt history when filtered history changes and reset navigation
@@ -106,76 +85,113 @@ export const usePromptHistory = ({
10685
}
10786
}, [historyIndex])
10887

88+
// Helper to set cursor position after React renders
89+
const setCursorPosition = useCallback(
90+
(textarea: HTMLTextAreaElement, position: number | "start" | "end", length?: number) => {
91+
setTimeout(() => {
92+
if (position === "start") {
93+
textarea.setSelectionRange(0, 0)
94+
} else if (position === "end") {
95+
const len = length ?? textarea.value.length
96+
textarea.setSelectionRange(len, len)
97+
} else {
98+
textarea.setSelectionRange(position, position)
99+
}
100+
}, 0)
101+
},
102+
[],
103+
)
104+
105+
// Helper to navigate to a specific history entry
106+
const navigateToHistory = useCallback(
107+
(newIndex: number, textarea: HTMLTextAreaElement, cursorPos: "start" | "end" = "start"): boolean => {
108+
if (newIndex < 0 || newIndex >= promptHistory.length) return false
109+
110+
const historicalPrompt = promptHistory[newIndex]
111+
if (!historicalPrompt) return false
112+
113+
setHistoryIndex(newIndex)
114+
setInputValue(historicalPrompt)
115+
setCursorPosition(textarea, cursorPos, historicalPrompt.length)
116+
117+
return true
118+
},
119+
[promptHistory, setInputValue, setCursorPosition],
120+
)
121+
122+
// Helper to return to current input
123+
const returnToCurrentInput = useCallback(
124+
(textarea: HTMLTextAreaElement, cursorPos: "start" | "end" = "end") => {
125+
setHistoryIndex(-1)
126+
setInputValue(tempInput)
127+
setCursorPosition(textarea, cursorPos, tempInput.length)
128+
},
129+
[tempInput, setInputValue, setCursorPosition],
130+
)
131+
109132
const handleHistoryNavigation = useCallback(
110133
(event: React.KeyboardEvent<HTMLTextAreaElement>, showContextMenu: boolean, isComposing: boolean): boolean => {
111134
// Handle prompt history navigation
112135
if (!showContextMenu && promptHistory.length > 0 && !isComposing) {
113136
const textarea = event.currentTarget
114137
const { selectionStart, selectionEnd, value } = textarea
115-
const lines = value.substring(0, selectionStart).split("\n")
116-
const currentLineIndex = lines.length - 1
117-
const totalLines = value.split("\n").length
118-
const isAtFirstLine = currentLineIndex === 0
119-
const isAtLastLine = currentLineIndex === totalLines - 1
120138
const hasSelection = selectionStart !== selectionEnd
139+
const isAtBeginning = selectionStart === 0 && selectionEnd === 0
140+
const isAtEnd = selectionStart === value.length && selectionEnd === value.length
121141

122-
// Only navigate history if cursor is at first/last line and no text is selected
123-
if (!hasSelection) {
124-
if (event.key === "ArrowUp" && isAtFirstLine) {
125-
event.preventDefault()
142+
// Check for modifier keys (Alt or Cmd/Ctrl)
143+
const hasModifier = event.altKey || event.metaKey || event.ctrlKey
126144

145+
// Handle explicit history navigation with Alt+Up/Down
146+
if (hasModifier && (event.key === "ArrowUp" || event.key === "ArrowDown")) {
147+
event.preventDefault()
148+
149+
if (event.key === "ArrowUp") {
127150
// Save current input if starting navigation
128-
if (historyIndex === -1 && inputValue.trim() !== "") {
151+
if (historyIndex === -1) {
129152
setTempInput(inputValue)
130153
}
154+
return navigateToHistory(historyIndex + 1, textarea, "start")
155+
} else {
156+
// ArrowDown
157+
if (historyIndex > 0) {
158+
return navigateToHistory(historyIndex - 1, textarea, "end")
159+
} else if (historyIndex === 0) {
160+
returnToCurrentInput(textarea, "end")
161+
return true
162+
}
163+
}
164+
}
131165

132-
// Navigate to previous prompt
133-
const newIndex = historyIndex + 1
134-
if (newIndex < promptHistory.length) {
135-
setHistoryIndex(newIndex)
136-
const historicalPrompt = promptHistory[newIndex]
137-
if (historicalPrompt) {
138-
setInputValue(historicalPrompt)
139-
setInputValueWithCursor({
140-
value: historicalPrompt,
141-
afterRender: "SET_CURSOR_FIRST_LINE",
142-
})
143-
}
166+
// Handle smart navigation without modifiers
167+
if (!hasSelection && !hasModifier) {
168+
// Only navigate history with UP if cursor is at the very beginning
169+
if (event.key === "ArrowUp" && isAtBeginning) {
170+
event.preventDefault()
171+
// Save current input if starting navigation
172+
if (historyIndex === -1) {
173+
setTempInput(inputValue)
144174
}
145-
return true
175+
return navigateToHistory(historyIndex + 1, textarea, "start")
146176
}
147177

148-
if (event.key === "ArrowDown" && isAtLastLine) {
178+
// Handle DOWN arrow - only in history navigation mode
179+
if (event.key === "ArrowDown" && historyIndex >= 0 && (isAtBeginning || isAtEnd)) {
149180
event.preventDefault()
150181

151-
// Navigate to next prompt
152182
if (historyIndex > 0) {
153-
const newIndex = historyIndex - 1
154-
setHistoryIndex(newIndex)
155-
const historicalPrompt = promptHistory[newIndex]
156-
if (historicalPrompt) {
157-
setInputValue(historicalPrompt)
158-
setInputValueWithCursor({
159-
value: historicalPrompt,
160-
afterRender: "SET_CURSOR_LAST_LINE",
161-
})
162-
}
183+
// Keep cursor position consistent with where we started
184+
return navigateToHistory(historyIndex - 1, textarea, isAtBeginning ? "start" : "end")
163185
} else if (historyIndex === 0) {
164-
// Return to current input
165-
setHistoryIndex(-1)
166-
setInputValue(tempInput)
167-
setInputValueWithCursor({
168-
value: tempInput,
169-
afterRender: "SET_CURSOR_START",
170-
})
186+
returnToCurrentInput(textarea, isAtBeginning ? "start" : "end")
187+
return true
171188
}
172-
return true
173189
}
174190
}
175191
}
176192
return false
177193
},
178-
[promptHistory, historyIndex, inputValue, tempInput, setInputValue],
194+
[promptHistory, historyIndex, inputValue, navigateToHistory, returnToCurrentInput],
179195
)
180196

181197
const resetHistoryNavigation = useCallback(() => {
@@ -189,8 +205,6 @@ export const usePromptHistory = ({
189205
tempInput,
190206
setTempInput,
191207
promptHistory,
192-
inputValueWithCursor,
193-
setInputValueWithCursor,
194208
handleHistoryNavigation,
195209
resetHistoryNavigation,
196210
resetOnInputChange,

0 commit comments

Comments
 (0)