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
+9
View File
@@ -77,6 +77,7 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
@@ -1363,6 +1364,8 @@
"react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="], "react-syntax-highlighter": ["react-syntax-highlighter@16.1.0", "", { "dependencies": { "@babel/runtime": "^7.28.4", "highlight.js": "^10.4.1", "highlightjs-vue": "^1.0.0", "lowlight": "^1.17.0", "prismjs": "^1.30.0", "refractor": "^5.0.0" }, "peerDependencies": { "react": ">= 0.14.0" } }, "sha512-E40/hBiP5rCNwkeBN1vRP+xow1X0pndinO+z3h7HLsHyjztbyjfzNWNKuAsJj+7DLam9iT4AaaOZnueCU+Nplg=="],
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
"read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="],
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -1595,6 +1598,12 @@
"use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
"use-composed-ref": ["use-composed-ref@1.4.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w=="],
"use-isomorphic-layout-effect": ["use-isomorphic-layout-effect@1.2.1", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tpZZ+EX0gaghDAiFR37hj5MgY6ZN55kLiPkJsKxBMZ6GZdOSPJXiOzPM984oPYZ5AnehYx5WQp1+ME8I/P/pRA=="],
"use-latest": ["use-latest@1.3.0", "", { "dependencies": { "use-isomorphic-layout-effect": "^1.1.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-mhg3xdm9NaM8q+gLT8KryJPnRFOz1/5XPBhmDEVZK1webPzDjrPk7f/mbpeLqTgB9msytYWANxgALOCJKnLvcQ=="],
"use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
+1
View File
@@ -19,6 +19,7 @@
"react-dom": "^19.2.3", "react-dom": "^19.2.3",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-syntax-highlighter": "^16.1.0", "react-syntax-highlighter": "^16.1.0",
"react-textarea-autosize": "^8.5.9",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"tailwind-merge": "^2.5.5" "tailwind-merge": "^2.5.5"
+524 -34
View File
@@ -1,46 +1,536 @@
import { useState } from 'react' import {
import { Button } from '@/components/ui/button' useState,
useCallback,
useRef,
useEffect,
useImperativeHandle,
forwardRef,
memo
} from 'react'
import TextareaAutosize from 'react-textarea-autosize'
import type { ModelMode, PermissionMode } from '@/types/api'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
import { useActiveWord } from '@/hooks/useActiveWord'
import { useActiveSuggestions } from '@/hooks/useActiveSuggestions'
import { applySuggestion } from '@/utils/applySuggestion'
import { getTelegramWebApp } from '@/hooks/useTelegram' import { getTelegramWebApp } from '@/hooks/useTelegram'
import { FloatingOverlay } from './ChatInput/FloatingOverlay'
import { Autocomplete } from './ChatInput/Autocomplete'
export function ChatInput(props: { // Types
disabled?: boolean export type SupportedKey = 'Enter' | 'Escape' | 'ArrowUp' | 'ArrowDown' | 'Tab'
onSend: (text: string) => void
}) {
const [text, setText] = useState('')
const trimmed = text.trim() export interface TextInputState {
text: string
const send = () => { selection: { start: number; end: number }
if (!trimmed) return
getTelegramWebApp()?.HapticFeedback?.impactOccurred('light')
props.onSend(trimmed)
setText('')
} }
return ( export interface ChatInputHandle {
<div className="bg-[var(--app-bg)] p-2"> focus: () => void
<div className="flex items-end gap-2"> blur: () => void
<textarea }
value={text}
onChange={(e) => setText(e.target.value)} export interface ChatInputProps {
placeholder="Type a message..." disabled?: boolean
className="flex-1 resize-none rounded-xl bg-[var(--app-secondary-bg)] p-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" onSend: (text: string) => void
rows={1} // Session data
disabled={props.disabled} sessionId?: string
onKeyDown={(e) => { permissionMode?: PermissionMode
if (e.key !== 'Enter') return modelMode?: ModelMode
if (e.shiftKey) return active?: boolean
// Callbacks
onPermissionModeChange?: (mode: PermissionMode) => void
onModelModeChange?: (mode: ModelMode) => void
onAbort?: () => Promise<void>
// Autocomplete
autocompletePrefixes?: string[]
autocompleteSuggestions?: (query: string) => Promise<Suggestion[]>
}
// Permission mode display config
const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const
const PERMISSION_MODE_LABELS: Record<string, string> = {
default: 'Default',
acceptEdits: 'Accept Edits',
plan: 'Plan Mode',
bypassPermissions: 'Bypass All'
}
// Model mode display config
const MODEL_MODES = ['default', 'sonnet', 'opus'] as const
const MODEL_MODE_LABELS: Record<string, string> = {
default: 'Default',
sonnet: 'Sonnet',
opus: 'Opus'
}
// Default empty suggestion handler
const defaultSuggestionHandler = async (): Promise<Suggestion[]> => []
export const ChatInput = memo(forwardRef<ChatInputHandle, ChatInputProps>(function ChatInput(props, ref) {
const {
disabled = false,
onSend,
permissionMode = 'default',
modelMode = 'default',
active = true,
onPermissionModeChange,
onModelModeChange,
onAbort,
autocompletePrefixes = ['@', '/'],
autocompleteSuggestions = defaultSuggestionHandler
} = props
// State
const [text, setText] = useState('')
const [inputState, setInputState] = useState<TextInputState>({
text: '',
selection: { start: 0, end: 0 }
})
const [showSettings, setShowSettings] = useState(false)
const [isAborting, setIsAborting] = useState(false)
// Refs
const textareaRef = useRef<HTMLTextAreaElement>(null)
// Imperative handle
useImperativeHandle(ref, () => ({
focus: () => textareaRef.current?.focus(),
blur: () => textareaRef.current?.blur()
}), [])
// Autocomplete hooks
const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes)
const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions(
activeWord,
autocompleteSuggestions,
{ clampSelection: true, wrapAround: true }
)
// Computed values
const trimmed = text.trim()
const hasText = trimmed.length > 0
const controlsDisabled = disabled || !active
// Haptic feedback helper
const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => {
const tg = getTelegramWebApp()
if (type === 'light') {
tg?.HapticFeedback?.impactOccurred('light')
} else if (type === 'success') {
tg?.HapticFeedback?.notificationOccurred('success')
} else {
tg?.HapticFeedback?.notificationOccurred('error')
}
}, [])
// Send message
const send = useCallback(() => {
if (!trimmed || controlsDisabled) return
haptic('light')
onSend(trimmed)
setText('')
setInputState({ text: '', selection: { start: 0, end: 0 } })
}, [trimmed, controlsDisabled, haptic, onSend])
// Handle suggestion selection
const handleSuggestionSelect = useCallback((index: number) => {
const suggestion = suggestions[index]
if (!suggestion || !textareaRef.current) return
const result = applySuggestion(
inputState.text,
inputState.selection,
suggestion.text,
autocompletePrefixes,
true
)
setText(result.text)
setInputState({
text: result.text,
selection: { start: result.cursorPosition, end: result.cursorPosition }
})
// Set cursor position
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.setSelectionRange(result.cursorPosition, result.cursorPosition)
textareaRef.current.focus()
}
}, 0)
haptic('light')
}, [suggestions, inputState, autocompletePrefixes, haptic])
// Handle abort
const handleAbort = useCallback(async () => {
if (!onAbort || isAborting) return
haptic('error')
setIsAborting(true)
const startTime = Date.now()
try {
await onAbort()
// Ensure minimum 300ms loading time
const elapsed = Date.now() - startTime
if (elapsed < 300) {
await new Promise(resolve => setTimeout(resolve, 300 - elapsed))
}
} catch (error) {
console.error('Abort failed:', error)
} finally {
setIsAborting(false)
}
}, [onAbort, isAborting, haptic])
// Handle keyboard events
const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
const key = e.key
// Handle autocomplete navigation first
if (suggestions.length > 0) {
if (key === 'ArrowUp') {
e.preventDefault()
moveUp()
return
} else if (key === 'ArrowDown') {
e.preventDefault()
moveDown()
return
} else if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) {
e.preventDefault()
const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0
handleSuggestionSelect(indexToSelect)
return
} else if (key === 'Escape') {
e.preventDefault()
clearSuggestions()
return
}
}
// Handle Escape for abort when no suggestions
if (key === 'Escape' && onAbort && !isAborting) {
e.preventDefault()
handleAbort()
return
}
// Handle Enter to send
if (key === 'Enter' && !e.shiftKey) {
e.preventDefault() e.preventDefault()
send() send()
}} return
}
// Handle Shift+Tab for permission mode switching
if (key === 'Tab' && e.shiftKey && onPermissionModeChange) {
e.preventDefault()
const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number])
const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length
onPermissionModeChange(PERMISSION_MODES[nextIndex])
haptic('light')
return
}
}, [
suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect,
onAbort, isAborting, handleAbort, send, onPermissionModeChange, permissionMode, haptic
])
// Handle global keyboard for model mode switching
useEffect(() => {
const handleGlobalKeyDown = (e: KeyboardEvent) => {
// Handle Cmd/Ctrl+M for model mode switching
if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) {
e.preventDefault()
const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number])
const nextIndex = (currentIndex + 1) % MODEL_MODES.length
onModelModeChange(MODEL_MODES[nextIndex])
haptic('light')
}
}
window.addEventListener('keydown', handleGlobalKeyDown)
return () => window.removeEventListener('keydown', handleGlobalKeyDown)
}, [modelMode, onModelModeChange, haptic])
// Handle text change
const handleChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newText = e.target.value
const selection = {
start: e.target.selectionStart,
end: e.target.selectionEnd
}
setText(newText)
setInputState({ text: newText, selection })
}, [])
// Handle selection change
const handleSelect = useCallback((e: React.SyntheticEvent<HTMLTextAreaElement>) => {
const target = e.target as HTMLTextAreaElement
setInputState(prev => ({
...prev,
selection: { start: target.selectionStart, end: target.selectionEnd }
}))
}, [])
// Handle settings toggle
const handleSettingsToggle = useCallback(() => {
haptic('light')
setShowSettings(prev => !prev)
}, [haptic])
// Handle permission mode change
const handlePermissionChange = useCallback((mode: PermissionMode) => {
haptic('light')
onPermissionModeChange?.(mode)
}, [haptic, onPermissionModeChange])
// Handle model mode change
const handleModelChange = useCallback((mode: ModelMode) => {
haptic('light')
onModelModeChange?.(mode)
}, [haptic, onModelModeChange])
// Close settings when clicking outside
useEffect(() => {
if (!showSettings) return
const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement
if (!target.closest('.settings-panel') && !target.closest('.settings-button')) {
setShowSettings(false)
}
}
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [showSettings])
return (
<div className="bg-[var(--app-bg)] px-2 pb-2 pt-1">
<div className="relative mx-auto w-full max-w-[720px]">
{/* Autocomplete overlay */}
{suggestions.length > 0 && (
<div className="absolute bottom-full left-0 right-0 z-50 mb-2">
<FloatingOverlay maxHeight={240}>
<Autocomplete
suggestions={suggestions as Suggestion[]}
selectedIndex={selectedIndex}
onSelect={handleSuggestionSelect}
/> />
<Button </FloatingOverlay>
onClick={send} </div>
disabled={props.disabled || !trimmed} )}
{/* Settings overlay */}
{showSettings && (
<div className="settings-panel absolute bottom-full left-0 right-0 z-50 mb-2">
<FloatingOverlay maxHeight={320}>
{/* Permission Mode Section */}
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
Permission Mode
</div>
{PERMISSION_MODES.map((mode) => (
<button
key={mode}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handlePermissionChange(mode)}
onMouseDown={(e) => e.preventDefault()}
> >
Send <div
</Button> className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
permissionMode === mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{permissionMode === mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={permissionMode === mode ? 'text-[var(--app-link)]' : ''}>
{PERMISSION_MODE_LABELS[mode]}
</span>
</button>
))}
</div>
{/* Divider */}
<div className="mx-3 h-px bg-[var(--app-divider)]" />
{/* Model Mode Section */}
<div className="py-2">
<div className="px-3 pb-1 text-xs font-semibold text-[var(--app-hint)]">
Model
</div>
{MODEL_MODES.map((mode) => (
<button
key={mode}
type="button"
disabled={controlsDisabled}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
controlsDisabled
? 'cursor-not-allowed opacity-50'
: 'cursor-pointer hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => handleModelChange(mode)}
onMouseDown={(e) => e.preventDefault()}
>
<div
className={`flex h-4 w-4 items-center justify-center rounded-full border-2 ${
modelMode === mode
? 'border-[var(--app-link)]'
: 'border-[var(--app-hint)]'
}`}
>
{modelMode === mode && (
<div className="h-2 w-2 rounded-full bg-[var(--app-link)]" />
)}
</div>
<span className={modelMode === mode ? 'text-[var(--app-link)]' : ''}>
{MODEL_MODE_LABELS[mode]}
</span>
</button>
))}
</div>
</FloatingOverlay>
</div>
)}
{/* Status bar */}
{(permissionMode && permissionMode !== 'default') && (
<div className="flex items-center justify-end px-2 pb-1">
<span className={`text-xs ${
permissionMode === 'acceptEdits' ? 'text-amber-500' :
permissionMode === 'bypassPermissions' ? 'text-red-500' :
permissionMode === 'plan' ? 'text-blue-500' :
'text-[var(--app-hint)]'
}`}>
{PERMISSION_MODE_LABELS[permissionMode]}
</span>
</div>
)}
{/* Unified panel */}
<div className="overflow-hidden rounded-[20px] bg-[var(--app-secondary-bg)]">
{/* Input area */}
<div className="flex items-center px-3 py-2">
<TextareaAutosize
ref={textareaRef}
value={text}
onChange={handleChange}
onSelect={handleSelect}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
disabled={controlsDisabled}
maxRows={5}
className="flex-1 resize-none bg-transparent text-sm leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
{/* Action buttons */}
<div className="flex items-center justify-between px-2 pb-2">
<div className="flex items-center gap-1">
{/* Settings button */}
{onPermissionModeChange && (
<button
type="button"
className="settings-button flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-[var(--app-fg)]"
onClick={handleSettingsToggle}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button>
)}
{/* Abort button */}
{onAbort && (
<button
type="button"
disabled={isAborting || controlsDisabled}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-fg)]/60 transition-colors hover:bg-[var(--app-bg)] hover:text-red-500 disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleAbort}
>
{isAborting ? (
<svg
className="animate-spin"
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<circle cx="12" cy="12" r="10" strokeOpacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" strokeOpacity="0.75" />
</svg>
) : (
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
height="18"
viewBox="0 0 16 16"
fill="currentColor"
>
<path d="M8 0a8 8 0 1 1 0 16A8 8 0 0 1 8 0ZM1.5 8a6.5 6.5 0 1 0 13 0 6.5 6.5 0 0 0-13 0Zm4-2.5a.5.5 0 0 1 .5-.5h4a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5h-4a.5.5 0 0 1-.5-.5v-4Z" />
</svg>
)}
</button>
)}
</div>
{/* Send button */}
<button
type="button"
disabled={controlsDisabled || !hasText}
className={`flex h-8 w-8 items-center justify-center rounded-full transition-colors ${
hasText && !controlsDisabled
? 'bg-black text-white'
: 'bg-[#C0C0C0] text-white'
} disabled:cursor-not-allowed`}
onClick={send}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<line x1="12" y1="19" x2="12" y2="5" />
<polyline points="5 12 12 5 19 12" />
</svg>
</button>
</div>
</div>
</div> </div>
</div> </div>
) )
} }))
@@ -0,0 +1,48 @@
import { memo } from 'react'
import type { Suggestion } from '@/hooks/useActiveSuggestions'
interface AutocompleteProps {
suggestions: Suggestion[]
selectedIndex: number
onSelect: (index: number) => void
}
/**
* Autocomplete suggestions list component
*/
export const Autocomplete = memo(function Autocomplete(props: AutocompleteProps) {
const { suggestions, selectedIndex, onSelect } = props
if (suggestions.length === 0) {
return null
}
return (
<div className="py-1">
{suggestions.map((suggestion, index) => (
<button
key={suggestion.key}
type="button"
className={`flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors ${
index === selectedIndex
? 'bg-[var(--app-button)] text-[var(--app-button-text)]'
: 'text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)]'
}`}
onClick={() => onSelect(index)}
onMouseDown={(e) => e.preventDefault()} // Prevent blur on textarea
>
<span className="font-medium">{suggestion.label}</span>
{suggestion.description && (
<span className={`truncate text-xs ${
index === selectedIndex
? 'opacity-80'
: 'text-[var(--app-hint)]'
}`}>
{suggestion.description}
</span>
)}
</button>
))}
</div>
)
})
@@ -0,0 +1,25 @@
import { memo, ReactNode } from 'react'
interface FloatingOverlayProps {
children: ReactNode
maxHeight?: number
}
/**
* A floating panel container with shadow and rounded corners
* Used for autocomplete suggestions and settings panels
*/
export const FloatingOverlay = memo(function FloatingOverlay(props: FloatingOverlayProps) {
const { children, maxHeight = 240 } = props
return (
<div
className="overflow-hidden rounded-xl border border-[var(--app-divider)] bg-[var(--app-bg)] shadow-lg"
style={{ maxHeight }}
>
<div className="overflow-y-auto" style={{ maxHeight }}>
{children}
</div>
</div>
)
})
+40 -4
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { ApiClient } from '@/api/client' import type { ApiClient } from '@/api/client'
import type { DecryptedMessage, Session } from '@/types/api' import type { DecryptedMessage, ModelMode, PermissionMode, Session } from '@/types/api'
import type { NormalizedMessage } from '@/chat/types' import type { NormalizedMessage } from '@/chat/types'
import { normalizeDecryptedMessage } from '@/chat/normalize' import { normalizeDecryptedMessage } from '@/chat/normalize'
import { reduceChatBlocks } from '@/chat/reducer' import { reduceChatBlocks } from '@/chat/reducer'
@@ -10,6 +10,7 @@ import { MessageBubble } from '@/components/MessageBubble'
import { ChatBlockList } from '@/components/ChatBlockList' import { ChatBlockList } from '@/components/ChatBlockList'
import { ChatInput } from '@/components/ChatInput' import { ChatInput } from '@/components/ChatInput'
import { useScrollToBottom } from '@/hooks/useScrollToBottom' import { useScrollToBottom } from '@/hooks/useScrollToBottom'
import { getTelegramWebApp } from '@/hooks/useTelegram'
export function SessionChat(props: { export function SessionChat(props: {
api: ApiClient api: ApiClient
@@ -62,13 +63,41 @@ export function SessionChat(props: {
const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced' const viewMode = import.meta.env.DEV ? debugViewMode : 'reduced'
const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode]) const scrollRef = useScrollToBottom([props.messages.length, reduced.blocks.length, viewMode])
// Permission mode change handler
const handlePermissionModeChange = useCallback(async (mode: PermissionMode) => {
try {
await props.api.setPermissionMode(props.session.id, mode as 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
console.error('Failed to set permission mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
// Model mode change handler
const handleModelModeChange = useCallback(async (mode: ModelMode) => {
try {
await props.api.setModelMode(props.session.id, mode as 'default' | 'sonnet' | 'opus')
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
props.onRefresh()
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
console.error('Failed to set model mode:', e)
}
}, [props.api, props.session.id, props.onRefresh])
// Abort handler
const handleAbort = useCallback(async () => {
await props.api.abortSession(props.session.id)
props.onRefresh()
}, [props.api, props.session.id, props.onRefresh])
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<SessionHeader <SessionHeader
api={props.api}
session={props.session} session={props.session}
onBack={props.onBack} onBack={props.onBack}
onRefresh={props.onRefresh}
/> />
{controlsDisabled ? ( {controlsDisabled ? (
@@ -159,6 +188,13 @@ export function SessionChat(props: {
<ChatInput <ChatInput
disabled={props.isSending || controlsDisabled} disabled={props.isSending || controlsDisabled}
onSend={props.onSend} onSend={props.onSend}
sessionId={props.session.id}
permissionMode={props.session.permissionMode}
modelMode={props.session.modelMode}
active={props.session.active}
onPermissionModeChange={handlePermissionModeChange}
onModelModeChange={handleModelModeChange}
onAbort={handleAbort}
/> />
</div> </div>
) )
+24 -115
View File
@@ -1,9 +1,5 @@
import { useMemo, useState } from 'react' import { useMemo } from 'react'
import type { ApiClient } from '@/api/client' import type { Session } from '@/types/api'
import type { ModelMode, PermissionMode, Session } from '@/types/api'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { getTelegramWebApp } from '@/hooks/useTelegram'
function getSessionTitle(session: Session): string { function getSessionTitle(session: Session): string {
if (session.metadata?.name) { if (session.metadata?.name) {
@@ -16,49 +12,36 @@ function getSessionTitle(session: Session): string {
return session.id.slice(0, 8) return session.id.slice(0, 8)
} }
function toPermissionMode(mode: PermissionMode): 'default' | 'acceptEdits' | 'bypassPermissions' | 'plan' {
if (mode === 'acceptEdits' || mode === 'bypassPermissions' || mode === 'plan') return mode
return 'default'
}
function toModelMode(mode: ModelMode): 'default' | 'sonnet' | 'opus' {
if (mode === 'sonnet' || mode === 'opus') return mode
return 'default'
}
export function SessionHeader(props: { export function SessionHeader(props: {
api: ApiClient
session: Session session: Session
onBack: () => void onBack: () => void
onRefresh: () => void
}) { }) {
const title = useMemo(() => getSessionTitle(props.session), [props.session]) const title = useMemo(() => getSessionTitle(props.session), [props.session])
const [settingsOpen, setSettingsOpen] = useState(false)
const [isWorking, setIsWorking] = useState(false)
const [error, setError] = useState<string | null>(null)
const permissionMode = toPermissionMode(props.session.permissionMode)
const modelMode = toModelMode(props.session.modelMode)
const controlsDisabled = isWorking || !props.session.active
async function run(action: () => Promise<void>) {
setIsWorking(true)
setError(null)
try {
await action()
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('success')
props.onRefresh()
setSettingsOpen(false)
} catch (e) {
getTelegramWebApp()?.HapticFeedback?.notificationOccurred('error')
setError(e instanceof Error ? e.message : 'Request failed')
} finally {
setIsWorking(false)
}
}
return ( return (
<div className="flex items-center gap-2 bg-[var(--app-bg)] p-3"> <div className="flex items-center gap-2 bg-[var(--app-bg)] p-3">
{/* Back button */}
<button
type="button"
onClick={props.onBack}
className="flex h-8 w-8 items-center justify-center rounded-full text-[var(--app-hint)] transition-colors hover:bg-[var(--app-secondary-bg)] hover:text-[var(--app-fg)]"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
{/* Session info */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<span <span
@@ -81,80 +64,6 @@ export function SessionHeader(props: {
{props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id} {props.session.metadata?.host ? `Host: ${props.session.metadata.host}` : props.session.id}
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Dialog open={settingsOpen} onOpenChange={setSettingsOpen}>
<DialogTrigger asChild>
<Button variant="secondary" size="sm">
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Session Settings</DialogTitle>
<DialogDescription className="truncate">
{props.session.metadata?.path ?? props.session.id}
</DialogDescription>
</DialogHeader>
<div className="mt-3 flex flex-col gap-4">
<div>
<div className="mb-2 text-sm font-medium">Permission Mode</div>
<div className="flex flex-wrap gap-2">
{(['default', 'acceptEdits', 'bypassPermissions', 'plan'] as const).map((mode) => (
<Button
key={mode}
size="sm"
variant={permissionMode === mode ? 'default' : 'secondary'}
disabled={controlsDisabled}
onClick={() => run(() => props.api.setPermissionMode(props.session.id, mode))}
>
{mode}
</Button>
))}
</div>
</div>
<div>
<div className="mb-2 text-sm font-medium">Model</div>
<div className="flex flex-wrap gap-2">
{(['default', 'sonnet', 'opus'] as const).map((model) => (
<Button
key={model}
size="sm"
variant={modelMode === model ? 'default' : 'secondary'}
disabled={controlsDisabled}
onClick={() => run(() => props.api.setModelMode(props.session.id, model))}
>
{model}
</Button>
))}
</div>
</div>
<div className="rounded-md bg-[var(--app-subtle-bg)] p-2 text-xs text-[var(--app-hint)]">
<div>Path: {props.session.metadata?.path ?? '(unknown)'}</div>
<div>Host: {props.session.metadata?.host ?? '(unknown)'}</div>
<div>Status: {props.session.active ? 'Active' : 'Inactive'}</div>
</div>
{error ? (
<div className="text-sm text-red-600">
{error}
</div>
) : null}
<Button
variant="destructive"
disabled={controlsDisabled}
onClick={() => run(() => props.api.abortSession(props.session.id))}
>
🛑 Abort Session
</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div> </div>
) )
} }
+191
View File
@@ -0,0 +1,191 @@
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
export interface Suggestion {
key: string
text: string
label: string
description?: string
}
interface SuggestionOptions {
clampSelection?: boolean // If true, clamp instead of preserving exact position
autoSelectFirst?: boolean // If true, automatically select first item when suggestions appear
wrapAround?: boolean // If true, wrap around when reaching top/bottom
}
/**
* A simple value sync class that processes the latest value
* Ensures only the most recent query is processed
*/
class ValueSync<T> {
private latestValue: T | undefined
private hasValue = false
private processing = false
private stopped = false
private command: (value: T) => Promise<void>
constructor(command: (value: T) => Promise<void>) {
this.command = command
}
setValue(value: T) {
if (this.stopped) return
this.latestValue = value
this.hasValue = true
if (!this.processing) {
this.processing = true
this.doSync()
}
}
stop() {
this.stopped = true
}
private async doSync() {
while (this.hasValue && !this.stopped) {
const value = this.latestValue!
this.hasValue = false
try {
await this.command(value)
} catch (e) {
console.error('ValueSync error:', e)
}
}
this.processing = false
}
}
/**
* Hook that manages autocomplete suggestions based on an active word query
* Returns: [suggestions, selectedIndex, moveUp, moveDown]
*/
export function useActiveSuggestions(
query: string | null,
handler: (query: string) => Promise<Suggestion[]>,
options: SuggestionOptions = {}
) {
const {
clampSelection = true,
autoSelectFirst = true,
wrapAround = true
} = options
// State for suggestions
const [state, setState] = useState<{
suggestions: Suggestion[]
selected: number
}>({
suggestions: [],
selected: -1
})
const moveUp = useCallback(() => {
setState((prev) => {
if (prev.suggestions.length === 0) return prev
if (prev.selected <= 0) {
// At top or nothing selected
if (wrapAround) {
return { ...prev, selected: prev.suggestions.length - 1 }
} else {
return { ...prev, selected: 0 }
}
}
// Move up
return { ...prev, selected: prev.selected - 1 }
})
}, [wrapAround])
const moveDown = useCallback(() => {
setState((prev) => {
if (prev.suggestions.length === 0) return prev
if (prev.selected >= prev.suggestions.length - 1) {
// At bottom
if (wrapAround) {
return { ...prev, selected: 0 }
} else {
return { ...prev, selected: prev.suggestions.length - 1 }
}
}
// If nothing selected, select first
if (prev.selected < 0) {
return { ...prev, selected: 0 }
}
// Move down
return { ...prev, selected: prev.selected + 1 }
})
}, [wrapAround])
const clear = useCallback(() => {
setState({ suggestions: [], selected: -1 })
}, [])
// Sync query to suggestions
const handlerRef = useRef(handler)
handlerRef.current = handler
const sync = useMemo(() => {
return new ValueSync<string | null>(async (query) => {
if (!query) return
const suggestions = await handlerRef.current(query)
setState((prev) => {
if (clampSelection) {
// Simply clamp the selection to valid range
let newSelected = prev.selected
if (suggestions.length === 0) {
newSelected = -1
} else if (autoSelectFirst && prev.suggestions.length === 0) {
// First time showing suggestions, auto-select first
newSelected = 0
} else if (prev.selected >= suggestions.length) {
// Selection is out of bounds, clamp to last item
newSelected = suggestions.length - 1
} else if (prev.selected < 0 && suggestions.length > 0 && autoSelectFirst) {
// No selection but we have suggestions
newSelected = 0
}
return { suggestions, selected: newSelected }
} else {
// Try to preserve selection by key (old behavior)
if (prev.selected >= 0 && prev.selected < prev.suggestions.length) {
const previousKey = prev.suggestions[prev.selected].key
const newIndex = suggestions.findIndex(s => s.key === previousKey)
if (newIndex !== -1) {
// Found the same key, keep it selected
return { suggestions, selected: newIndex }
}
}
// Key not found or no previous selection, clamp the selection
const clampedSelection = Math.min(prev.selected, suggestions.length - 1)
return {
suggestions,
selected: clampedSelection < 0 && suggestions.length > 0 && autoSelectFirst ? 0 : clampedSelection
}
}
})
})
}, [clampSelection, autoSelectFirst])
useEffect(() => {
sync.setValue(query)
}, [query, sync])
useEffect(() => {
return () => sync.stop()
}, [sync])
// If no query return empty suggestions
if (!query) {
return [[], -1, moveUp, moveDown, clear] as const
}
// Return state suggestions
return [state.suggestions, state.selected, moveUp, moveDown, clear] as const
}
+20
View File
@@ -0,0 +1,20 @@
import { useMemo } from 'react'
import { findActiveWord } from '@/utils/findActiveWord'
/**
* Hook that detects the active word at the cursor position
* Returns the active word string if cursor is on a word starting with one of the prefixes
*/
export function useActiveWord(
text: string,
selection: { start: number; end: number },
prefixes: string[] = ['@', '/']
) {
return useMemo(() => {
const w = findActiveWord(text, selection, prefixes)
if (w) {
return w.activeWord
}
return null
}, [text, selection.start, selection.end, prefixes])
}
+2
View File
@@ -13,6 +13,7 @@
/* Theme-aware colors (light mode defaults) */ /* Theme-aware colors (light mode defaults) */
--app-border: rgba(0, 0, 0, 0.1); --app-border: rgba(0, 0, 0, 0.1);
--app-divider: rgba(0, 0, 0, 0.08);
--app-subtle-bg: rgba(0, 0, 0, 0.05); --app-subtle-bg: rgba(0, 0, 0, 0.05);
--app-code-bg: #f6f8fa; --app-code-bg: #f6f8fa;
--app-inline-code-bg: rgba(0, 0, 0, 0.08); --app-inline-code-bg: rgba(0, 0, 0, 0.08);
@@ -37,6 +38,7 @@
[data-theme="dark"] { [data-theme="dark"] {
--app-border: rgba(255, 255, 255, 0.1); --app-border: rgba(255, 255, 255, 0.1);
--app-divider: rgba(255, 255, 255, 0.08);
--app-subtle-bg: rgba(255, 255, 255, 0.05); --app-subtle-bg: rgba(255, 255, 255, 0.05);
--app-code-bg: #282c34; --app-code-bg: #282c34;
--app-inline-code-bg: rgba(255, 255, 255, 0.1); --app-inline-code-bg: rgba(255, 255, 255, 0.1);
+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 ''
}