feat: refactor ChatInput with autocomplete, suggestion management, and settings integration

Extract word detection and suggestion logic into reusable utilities and hooks.
Add FloatingOverlay and Autocomplete components for intelligent input assistance.
Integrate settings panel and abort button directly into ChatInput component.
Simplify SessionHeader by removing settings dialog (now in ChatInput).
This commit is contained in:
weishu
2025-12-17 09:13:15 +08:00
parent a307fd4893
commit ec3a849cbd
12 changed files with 1130 additions and 151 deletions
+61
View File
@@ -0,0 +1,61 @@
import { findActiveWord } from '@/utils/findActiveWord'
interface Selection {
start: number
end: number
}
/**
* Applies a suggestion by replacing the active word with the provided suggestion text
* @param content The full text content
* @param selection The current cursor position/selection
* @param suggestion The suggestion text to insert (e.g., "@john_smith")
* @param prefixes Array of prefix characters to look for (e.g., ['@', '/'])
* @param addSpace Whether to add a space after the suggestion (default: true)
* @returns An object containing the new text and cursor position
*/
export function applySuggestion(
content: string,
selection: Selection,
suggestion: string,
prefixes: string[] = ['@', '/'],
addSpace: boolean = true
): { text: string; cursorPosition: number } {
// Find the active word at the current position
const activeWord = findActiveWord(content, selection, prefixes)
if (!activeWord) {
// No active word found, just insert the suggestion at cursor position
const beforeCursor = content.substring(0, selection.start)
const afterCursor = content.substring(selection.end)
const suggestionWithSpace = addSpace ? suggestion + ' ' : suggestion
return {
text: beforeCursor + suggestionWithSpace + afterCursor,
cursorPosition: selection.start + suggestionWithSpace.length
}
}
// Replace the complete word (from offset to endOffset) with the suggestion
const beforeWord = content.substring(0, activeWord.offset)
const afterWord = content.substring(activeWord.endOffset)
// Add space after suggestion if requested
let suggestionToInsert = suggestion
if (addSpace) {
// Add space if:
// 1. There's no text after (end of string)
// 2. There's text after but no space
if (afterWord.length === 0 || afterWord[0] !== ' ') {
suggestionToInsert += ' '
}
}
const newText = beforeWord + suggestionToInsert + afterWord
const newCursorPosition = activeWord.offset + suggestionToInsert.length
return {
text: newText,
cursorPosition: newCursorPosition
}
}
+187
View File
@@ -0,0 +1,187 @@
/**
* Finds the active word at the cursor position that starts with one of the given prefixes
* @param content The full text content
* @param selection The current cursor position/selection
* @param prefixes Array of prefix characters to look for (e.g., ['@', '/'])
* @returns An object containing word info, or undefined if no prefixed word is found
*/
// Characters that stop the active word search
const STOP_CHARACTERS = ['\n', ',', '(', ')', '[', ']', '{', '}', '<', '>', ';', '!', '?', '.']
interface Selection {
start: number
end: number
}
export interface ActiveWord {
word: string // Full word from prefix to end (e.g., "@username")
activeWord: string // Part from prefix to cursor (e.g., "@use")
offset: number // Starting position of the word
length: number // Total length of the complete word
activeLength: number // Length from prefix to cursor
endOffset: number // Position where the word ends (offset + length)
}
function findActiveWordStart(
content: string,
selection: Selection,
prefixes: string[]
): number {
let startIndex = selection.start - 1
let spaceIndex = -1
let foundPrefix = false
let prefixIndex = -1
while (startIndex >= 0) {
const char = content.charAt(startIndex)
// Check if we hit a space
if (char === ' ') {
if (foundPrefix) {
// We found a prefix earlier, return its position
return prefixIndex
}
if (spaceIndex >= 0) {
// Multiple spaces, stop here
return spaceIndex + 1
} else {
spaceIndex = startIndex
startIndex--
}
}
// Check if this is a prefix character at word boundary
else if (
prefixes.includes(char) &&
(startIndex === 0 || content.charAt(startIndex - 1) === ' ' || content.charAt(startIndex - 1) === '\n')
) {
// For @ prefix, continue searching backwards to include the entire file path
if (char === '@') {
foundPrefix = true
prefixIndex = startIndex
// Return immediately for @ at word boundary
return startIndex
} else {
return startIndex
}
}
// Check if we hit a stop character
else if (STOP_CHARACTERS.includes(char)) {
if (foundPrefix) {
return prefixIndex
}
return startIndex + 1
}
// Continue searching backwards
else {
startIndex--
}
}
// Reached beginning of text
if (foundPrefix) {
return prefixIndex
}
return (spaceIndex >= 0 ? spaceIndex : startIndex) + 1
}
function findActiveWordEnd(
content: string,
cursorPos: number,
wordStartPos?: number
): number {
let endIndex = cursorPos
// Check if this is a file path (starts with @ and may contain /)
let isFilePath = false
if (wordStartPos !== undefined && wordStartPos >= 0 && wordStartPos < content.length) {
isFilePath = content.charAt(wordStartPos) === '@'
}
while (endIndex < content.length) {
const char = content.charAt(endIndex)
// For file paths starting with @, don't stop at / or .
if (isFilePath && (char === '/' || char === '.')) {
endIndex++
continue
}
// Stop at spaces or stop characters
if (char === ' ' || STOP_CHARACTERS.includes(char)) {
break
}
endIndex++
}
return endIndex
}
export function findActiveWord(
content: string,
selection: Selection,
prefixes: string[] = ['@', '/']
): ActiveWord | undefined {
// Only detect when cursor is at a single point (no text selected)
if (selection.start !== selection.end) {
return undefined
}
// Don't detect if cursor is at the very beginning
if (selection.start === 0) {
return undefined
}
const startIndex = findActiveWordStart(content, selection, prefixes)
const activeWordPart = content.substring(startIndex, selection.end)
// Check if the active word ends with a space - if so, no active word
if (activeWordPart.endsWith(' ')) {
return undefined
}
// Check if the word starts with one of our prefixes
if (activeWordPart.length > 0) {
const firstChar = activeWordPart.charAt(0)
if (prefixes.includes(firstChar)) {
// Find where the word ends after the cursor
// Pass the start position to help determine if this is a file path
const endIndex = findActiveWordEnd(content, selection.end, startIndex)
const fullWord = content.substring(startIndex, endIndex)
// Don't return just the prefix character alone
if (activeWordPart.length === 1 && fullWord.length === 1) {
return {
word: fullWord,
activeWord: activeWordPart,
offset: startIndex,
length: fullWord.length,
activeLength: activeWordPart.length,
endOffset: endIndex
} // Return single prefix to show suggestions immediately
}
return {
word: fullWord,
activeWord: activeWordPart,
offset: startIndex,
length: fullWord.length,
activeLength: activeWordPart.length,
endOffset: endIndex
}
}
}
return undefined
}
/**
* Extracts just the query part without the prefix
* @param activeWord The active word including prefix
* @returns The query string without prefix
*/
export function getActiveWordQuery(activeWord: string): string {
if (activeWord.length > 1) {
return activeWord.substring(1)
}
return ''
}