mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-06 06:41:56 +00:00
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:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 ''
|
||||
}
|
||||
Reference in New Issue
Block a user