@shareai-lab/kode 1.0.71 → 1.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +142 -1
  2. package/README.zh-CN.md +47 -1
  3. package/package.json +5 -1
  4. package/src/ProjectOnboarding.tsx +47 -29
  5. package/src/Tool.ts +33 -4
  6. package/src/commands/agents.tsx +3401 -0
  7. package/src/commands/help.tsx +2 -2
  8. package/src/commands/resume.tsx +2 -1
  9. package/src/commands/terminalSetup.ts +4 -4
  10. package/src/commands.ts +3 -0
  11. package/src/components/ApproveApiKey.tsx +1 -1
  12. package/src/components/Config.tsx +10 -6
  13. package/src/components/ConsoleOAuthFlow.tsx +5 -4
  14. package/src/components/CustomSelect/select-option.tsx +28 -2
  15. package/src/components/CustomSelect/select.tsx +14 -5
  16. package/src/components/CustomSelect/theme.ts +45 -0
  17. package/src/components/Help.tsx +4 -4
  18. package/src/components/InvalidConfigDialog.tsx +1 -1
  19. package/src/components/LogSelector.tsx +1 -1
  20. package/src/components/MCPServerApprovalDialog.tsx +1 -1
  21. package/src/components/Message.tsx +2 -0
  22. package/src/components/ModelListManager.tsx +10 -6
  23. package/src/components/ModelSelector.tsx +201 -23
  24. package/src/components/ModelStatusDisplay.tsx +7 -5
  25. package/src/components/PromptInput.tsx +117 -87
  26. package/src/components/SentryErrorBoundary.ts +3 -3
  27. package/src/components/StickerRequestForm.tsx +16 -0
  28. package/src/components/StructuredDiff.tsx +36 -29
  29. package/src/components/TextInput.tsx +13 -0
  30. package/src/components/TodoItem.tsx +11 -0
  31. package/src/components/TrustDialog.tsx +1 -1
  32. package/src/components/messages/AssistantLocalCommandOutputMessage.tsx +5 -1
  33. package/src/components/messages/AssistantToolUseMessage.tsx +14 -4
  34. package/src/components/messages/TaskProgressMessage.tsx +32 -0
  35. package/src/components/messages/TaskToolMessage.tsx +58 -0
  36. package/src/components/permissions/FallbackPermissionRequest.tsx +2 -4
  37. package/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx +1 -1
  38. package/src/components/permissions/FileEditPermissionRequest/FileEditToolDiff.tsx +5 -3
  39. package/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx +1 -1
  40. package/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx +5 -3
  41. package/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx +2 -4
  42. package/src/components/permissions/PermissionRequest.tsx +3 -5
  43. package/src/constants/macros.ts +2 -0
  44. package/src/constants/modelCapabilities.ts +179 -0
  45. package/src/constants/models.ts +90 -0
  46. package/src/constants/product.ts +1 -1
  47. package/src/context.ts +7 -7
  48. package/src/entrypoints/cli.tsx +23 -3
  49. package/src/entrypoints/mcp.ts +10 -10
  50. package/src/hooks/useCanUseTool.ts +1 -1
  51. package/src/hooks/useTextInput.ts +5 -2
  52. package/src/hooks/useUnifiedCompletion.ts +1404 -0
  53. package/src/messages.ts +1 -0
  54. package/src/query.ts +3 -0
  55. package/src/screens/ConfigureNpmPrefix.tsx +1 -1
  56. package/src/screens/Doctor.tsx +1 -1
  57. package/src/screens/REPL.tsx +15 -9
  58. package/src/services/adapters/base.ts +38 -0
  59. package/src/services/adapters/chatCompletions.ts +90 -0
  60. package/src/services/adapters/responsesAPI.ts +170 -0
  61. package/src/services/claude.ts +198 -62
  62. package/src/services/customCommands.ts +43 -22
  63. package/src/services/gpt5ConnectionTest.ts +340 -0
  64. package/src/services/mcpClient.ts +1 -1
  65. package/src/services/mentionProcessor.ts +273 -0
  66. package/src/services/modelAdapterFactory.ts +69 -0
  67. package/src/services/openai.ts +521 -12
  68. package/src/services/responseStateManager.ts +90 -0
  69. package/src/services/systemReminder.ts +113 -12
  70. package/src/test/testAdapters.ts +96 -0
  71. package/src/tools/AskExpertModelTool/AskExpertModelTool.tsx +120 -56
  72. package/src/tools/BashTool/BashTool.tsx +4 -31
  73. package/src/tools/BashTool/BashToolResultMessage.tsx +1 -1
  74. package/src/tools/BashTool/OutputLine.tsx +1 -0
  75. package/src/tools/FileEditTool/FileEditTool.tsx +4 -5
  76. package/src/tools/FileReadTool/FileReadTool.tsx +43 -10
  77. package/src/tools/MCPTool/MCPTool.tsx +2 -1
  78. package/src/tools/MultiEditTool/MultiEditTool.tsx +2 -2
  79. package/src/tools/NotebookReadTool/NotebookReadTool.tsx +15 -23
  80. package/src/tools/StickerRequestTool/StickerRequestTool.tsx +1 -1
  81. package/src/tools/TaskTool/TaskTool.tsx +170 -86
  82. package/src/tools/TaskTool/prompt.ts +61 -25
  83. package/src/tools/ThinkTool/ThinkTool.tsx +1 -3
  84. package/src/tools/TodoWriteTool/TodoWriteTool.tsx +11 -10
  85. package/src/tools/lsTool/lsTool.tsx +5 -2
  86. package/src/tools.ts +16 -16
  87. package/src/types/conversation.ts +51 -0
  88. package/src/types/logs.ts +58 -0
  89. package/src/types/modelCapabilities.ts +64 -0
  90. package/src/types/notebook.ts +87 -0
  91. package/src/utils/advancedFuzzyMatcher.ts +290 -0
  92. package/src/utils/agentLoader.ts +284 -0
  93. package/src/utils/ask.tsx +1 -0
  94. package/src/utils/commands.ts +1 -1
  95. package/src/utils/commonUnixCommands.ts +161 -0
  96. package/src/utils/config.ts +173 -2
  97. package/src/utils/conversationRecovery.ts +1 -0
  98. package/src/utils/debugLogger.ts +13 -13
  99. package/src/utils/exampleCommands.ts +1 -0
  100. package/src/utils/fuzzyMatcher.ts +328 -0
  101. package/src/utils/messages.tsx +6 -5
  102. package/src/utils/responseState.ts +23 -0
  103. package/src/utils/secureFile.ts +559 -0
  104. package/src/utils/terminal.ts +1 -0
  105. package/src/utils/theme.ts +11 -0
  106. package/src/hooks/useSlashCommandTypeahead.ts +0 -137
@@ -0,0 +1,1404 @@
1
+ import { useState, useCallback, useEffect, useRef } from 'react'
2
+ import { useInput } from 'ink'
3
+ import { existsSync, statSync, readdirSync } from 'fs'
4
+ import { join, dirname, basename, resolve } from 'path'
5
+ import { getCwd } from '../utils/state'
6
+ import { getCommand } from '../commands'
7
+ import { getActiveAgents } from '../utils/agentLoader'
8
+ import { getModelManager } from '../utils/model'
9
+ import { glob } from 'glob'
10
+ import { matchCommands } from '../utils/fuzzyMatcher'
11
+ import {
12
+ getCommonSystemCommands,
13
+ getCommandPriority,
14
+ getEssentialCommands,
15
+ getMinimalFallbackCommands
16
+ } from '../utils/commonUnixCommands'
17
+ import type { Command } from '../commands'
18
+
19
+ // Unified suggestion type for all completion types
20
+ export interface UnifiedSuggestion {
21
+ value: string
22
+ displayValue: string
23
+ type: 'command' | 'agent' | 'file' | 'ask'
24
+ icon?: string
25
+ score: number
26
+ metadata?: any
27
+ // Clean type system for smart matching
28
+ isSmartMatch?: boolean // Instead of magic string checking
29
+ originalContext?: 'mention' | 'file' | 'command' // Track source context
30
+ }
31
+
32
+ interface CompletionContext {
33
+ type: 'command' | 'agent' | 'file' | null
34
+ prefix: string
35
+ startPos: number
36
+ endPos: number
37
+ }
38
+
39
+ // Terminal behavior state for preview and cycling
40
+ interface TerminalState {
41
+ originalWord: string
42
+ wordContext: { start: number; end: number } | null
43
+ isPreviewMode: boolean
44
+ }
45
+
46
+ interface Props {
47
+ input: string
48
+ cursorOffset: number
49
+ onInputChange: (value: string) => void
50
+ setCursorOffset: (offset: number) => void
51
+ commands: Command[]
52
+ onSubmit?: (value: string, isSubmittingSlashCommand?: boolean) => void
53
+ }
54
+
55
+ /**
56
+ * Unified completion system - Linus approved
57
+ * One hook to rule them all, no bullshit, no complexity
58
+ */
59
+ // Unified completion state - single source of truth
60
+ interface CompletionState {
61
+ suggestions: UnifiedSuggestion[]
62
+ selectedIndex: number
63
+ isActive: boolean
64
+ context: CompletionContext | null
65
+ preview: {
66
+ isActive: boolean
67
+ originalInput: string
68
+ wordRange: [number, number]
69
+ } | null
70
+ emptyDirMessage: string
71
+ suppressUntil: number // timestamp for suppression
72
+ }
73
+
74
+ const INITIAL_STATE: CompletionState = {
75
+ suggestions: [],
76
+ selectedIndex: 0,
77
+ isActive: false,
78
+ context: null,
79
+ preview: null,
80
+ emptyDirMessage: '',
81
+ suppressUntil: 0
82
+ }
83
+
84
+ export function useUnifiedCompletion({
85
+ input,
86
+ cursorOffset,
87
+ onInputChange,
88
+ setCursorOffset,
89
+ commands,
90
+ onSubmit,
91
+ }: Props) {
92
+ // Single state for entire completion system - Linus approved
93
+ const [state, setState] = useState<CompletionState>(INITIAL_STATE)
94
+
95
+ // State update helpers - clean and simple
96
+ const updateState = useCallback((updates: Partial<CompletionState>) => {
97
+ setState(prev => ({ ...prev, ...updates }))
98
+ }, [])
99
+
100
+ const resetCompletion = useCallback(() => {
101
+ setState(prev => ({
102
+ ...prev,
103
+ suggestions: [],
104
+ selectedIndex: 0,
105
+ isActive: false,
106
+ context: null,
107
+ preview: null,
108
+ emptyDirMessage: ''
109
+ }))
110
+ }, [])
111
+
112
+ const activateCompletion = useCallback((suggestions: UnifiedSuggestion[], context: CompletionContext) => {
113
+ setState(prev => ({
114
+ ...prev,
115
+ suggestions: suggestions, // Keep the order from generateSuggestions (already sorted with weights)
116
+ selectedIndex: 0,
117
+ isActive: true,
118
+ context,
119
+ preview: null
120
+ }))
121
+ }, [])
122
+
123
+ // Direct state access - no legacy wrappers needed
124
+ const { suggestions, selectedIndex, isActive, emptyDirMessage } = state
125
+
126
+ // Find common prefix among suggestions (terminal behavior)
127
+ const findCommonPrefix = useCallback((suggestions: UnifiedSuggestion[]): string => {
128
+ if (suggestions.length === 0) return ''
129
+ if (suggestions.length === 1) return suggestions[0].value
130
+
131
+ let prefix = suggestions[0].value
132
+
133
+ for (let i = 1; i < suggestions.length; i++) {
134
+ const str = suggestions[i].value
135
+ let j = 0
136
+ while (j < prefix.length && j < str.length && prefix[j] === str[j]) {
137
+ j++
138
+ }
139
+ prefix = prefix.slice(0, j)
140
+
141
+ if (prefix.length === 0) return ''
142
+ }
143
+
144
+ return prefix
145
+ }, [])
146
+
147
+ // Clean word detection - Linus approved simplicity
148
+ const getWordAtCursor = useCallback((): CompletionContext | null => {
149
+ if (!input) return null
150
+
151
+ // IMPORTANT: Only match the word/prefix BEFORE the cursor
152
+ // Don't include text after cursor to avoid confusion
153
+ let start = cursorOffset
154
+
155
+ // Move start backwards to find word beginning
156
+ // Stop at whitespace or special boundaries
157
+ while (start > 0) {
158
+ const char = input[start - 1]
159
+ // Stop at whitespace
160
+ if (/\s/.test(char)) break
161
+
162
+ // For @mentions, include @ and stop
163
+ if (char === '@' && start < cursorOffset) {
164
+ start--
165
+ break
166
+ }
167
+
168
+ // For paths, be smarter about / handling
169
+ if (char === '/') {
170
+ // Look ahead to see what we've collected so far
171
+ const collectedSoFar = input.slice(start, cursorOffset)
172
+
173
+ // If we already have a path component, this / is part of the path
174
+ if (collectedSoFar.includes('/') || collectedSoFar.includes('.')) {
175
+ start--
176
+ continue
177
+ }
178
+
179
+ // Check if this is part of a path pattern like ./ or ../ or ~/
180
+ if (start > 1) {
181
+ const prevChar = input[start - 2]
182
+ if (prevChar === '.' || prevChar === '~') {
183
+ // It's part of ./ or ../ or ~/ - keep going
184
+ start--
185
+ continue
186
+ }
187
+ }
188
+
189
+ // Check if this is a standalone / at the beginning (command)
190
+ if (start === 1 || (start > 1 && /\s/.test(input[start - 2]))) {
191
+ start--
192
+ break // It's a command slash
193
+ }
194
+
195
+ // Otherwise treat as path separator
196
+ start--
197
+ continue
198
+ }
199
+
200
+ // Special handling for dots in paths
201
+ if (char === '.' && start > 0) {
202
+ // Check if this might be start of ./ or ../
203
+ const nextChar = start < input.length ? input[start] : ''
204
+ if (nextChar === '/' || nextChar === '.') {
205
+ start--
206
+ continue // Part of a path pattern
207
+ }
208
+ }
209
+
210
+ start--
211
+ }
212
+
213
+ // The word is from start to cursor position (not beyond)
214
+ const word = input.slice(start, cursorOffset)
215
+ if (!word) return null
216
+
217
+ // Priority-based type detection - no special cases needed
218
+ if (word.startsWith('/')) {
219
+ const beforeWord = input.slice(0, start).trim()
220
+ const isCommand = beforeWord === '' && !word.includes('/', 1)
221
+ return {
222
+ type: isCommand ? 'command' : 'file',
223
+ prefix: isCommand ? word.slice(1) : word,
224
+ startPos: start,
225
+ endPos: cursorOffset // Use cursor position as end
226
+ }
227
+ }
228
+
229
+ if (word.startsWith('@')) {
230
+ const content = word.slice(1) // Remove @
231
+
232
+ // Check if this looks like an email (contains @ in the middle)
233
+ if (word.includes('@', 1)) {
234
+ // This looks like an email, treat as regular text
235
+ return null
236
+ }
237
+
238
+ // Trigger completion for @mentions (agents, ask-models, files)
239
+ return {
240
+ type: 'agent', // This will trigger mixed agent+file completion
241
+ prefix: content,
242
+ startPos: start,
243
+ endPos: cursorOffset // Use cursor position as end
244
+ }
245
+ }
246
+
247
+ // Everything else defaults to file completion
248
+ return {
249
+ type: 'file',
250
+ prefix: word,
251
+ startPos: start,
252
+ endPos: cursorOffset // Use cursor position as end
253
+ }
254
+ }, [input, cursorOffset])
255
+
256
+ // System commands cache - populated dynamically from $PATH
257
+ const [systemCommands, setSystemCommands] = useState<string[]>([])
258
+ const [isLoadingCommands, setIsLoadingCommands] = useState(false)
259
+
260
+ // Dynamic command classification based on intrinsic features
261
+ const classifyCommand = useCallback((cmd: string): 'core' | 'common' | 'dev' | 'system' => {
262
+ const lowerCmd = cmd.toLowerCase()
263
+ let score = 0
264
+
265
+ // === FEATURE 1: Name Length & Complexity ===
266
+ // Short, simple names are usually core commands
267
+ if (cmd.length <= 4) score += 40
268
+ else if (cmd.length <= 6) score += 20
269
+ else if (cmd.length <= 8) score += 10
270
+ else if (cmd.length > 15) score -= 30 // Very long names are specialized
271
+
272
+ // === FEATURE 2: Character Patterns ===
273
+ // Simple alphabetic names are more likely core
274
+ if (/^[a-z]+$/.test(lowerCmd)) score += 30
275
+
276
+ // Mixed case, numbers, dots suggest specialized tools
277
+ if (/[A-Z]/.test(cmd)) score -= 15
278
+ if (/\d/.test(cmd)) score -= 20
279
+ if (cmd.includes('.')) score -= 25
280
+ if (cmd.includes('-')) score -= 10
281
+ if (cmd.includes('_')) score -= 15
282
+
283
+ // === FEATURE 3: Linguistic Patterns ===
284
+ // Single, common English words
285
+ const commonWords = ['list', 'copy', 'move', 'find', 'print', 'show', 'edit', 'view']
286
+ if (commonWords.some(word => lowerCmd.includes(word.slice(0, 3)))) score += 25
287
+
288
+ // Domain-specific prefixes/suffixes
289
+ const devPrefixes = ['git', 'npm', 'node', 'py', 'docker', 'kubectl']
290
+ if (devPrefixes.some(prefix => lowerCmd.startsWith(prefix))) score += 15
291
+
292
+ // System/daemon indicators
293
+ const systemIndicators = ['daemon', 'helper', 'responder', 'service', 'd$', 'ctl$']
294
+ if (systemIndicators.some(indicator =>
295
+ indicator.endsWith('$') ? lowerCmd.endsWith(indicator.slice(0, -1)) : lowerCmd.includes(indicator)
296
+ )) score -= 40
297
+
298
+ // === FEATURE 4: File Extension Indicators ===
299
+ // Commands with extensions are usually scripts/specialized tools
300
+ if (/\.(pl|py|sh|rb|js)$/.test(lowerCmd)) score -= 35
301
+
302
+ // === FEATURE 5: Path Location Heuristics ===
303
+ // Note: We don't have path info here, but can infer from name patterns
304
+ // Commands that look like they belong in /usr/local/bin or specialized dirs
305
+ const buildToolPatterns = ['bindep', 'render', 'mako', 'webpack', 'babel', 'eslint']
306
+ if (buildToolPatterns.some(pattern => lowerCmd.includes(pattern))) score -= 25
307
+
308
+ // === FEATURE 6: Vowel/Consonant Patterns ===
309
+ // Unix commands often have abbreviated names with few vowels
310
+ const vowelRatio = (lowerCmd.match(/[aeiou]/g) || []).length / lowerCmd.length
311
+ if (vowelRatio < 0.2) score += 15 // Very few vowels (like 'ls', 'cp', 'mv')
312
+ if (vowelRatio > 0.5) score -= 10 // Too many vowels (usually full words)
313
+
314
+ // === CLASSIFICATION BASED ON SCORE ===
315
+ if (score >= 50) return 'core' // 50+: Core unix commands
316
+ if (score >= 20) return 'common' // 20-49: Common dev tools
317
+ if (score >= -10) return 'dev' // -10-19: Specialized dev tools
318
+ return 'system' // <-10: System/edge commands
319
+ }, [])
320
+
321
+ // Load system commands from PATH (like real terminal)
322
+ const loadSystemCommands = useCallback(async () => {
323
+ if (systemCommands.length > 0 || isLoadingCommands) return // Already loaded or loading
324
+
325
+ setIsLoadingCommands(true)
326
+ try {
327
+ const { readdirSync, statSync } = await import('fs')
328
+ const pathDirs = (process.env.PATH || '').split(':').filter(Boolean)
329
+ const commandSet = new Set<string>()
330
+
331
+ // Get essential commands from utils
332
+ const essentialCommands = getEssentialCommands()
333
+
334
+ // Add essential commands first
335
+ essentialCommands.forEach(cmd => commandSet.add(cmd))
336
+
337
+ // Scan PATH directories for executables
338
+ for (const dir of pathDirs) {
339
+ try {
340
+ if (readdirSync && statSync) {
341
+ const entries = readdirSync(dir)
342
+ for (const entry of entries) {
343
+ try {
344
+ const fullPath = `${dir}/${entry}`
345
+ const stats = statSync(fullPath)
346
+ // Check if it's executable (rough check)
347
+ if (stats.isFile() && (stats.mode & 0o111) !== 0) {
348
+ commandSet.add(entry)
349
+ }
350
+ } catch {
351
+ // Skip files we can't stat
352
+ }
353
+ }
354
+ }
355
+ } catch {
356
+ // Skip directories we can't read
357
+ }
358
+ }
359
+
360
+ const commands = Array.from(commandSet).sort()
361
+ setSystemCommands(commands)
362
+ } catch (error) {
363
+ console.warn('Failed to load system commands, using fallback:', error)
364
+ // Use minimal fallback commands from utils if system scan fails
365
+ setSystemCommands(getMinimalFallbackCommands())
366
+ } finally {
367
+ setIsLoadingCommands(false)
368
+ }
369
+ }, [systemCommands.length, isLoadingCommands])
370
+
371
+ // Load commands on first use
372
+ useEffect(() => {
373
+ loadSystemCommands()
374
+ }, [loadSystemCommands])
375
+
376
+ // Generate command suggestions (slash commands)
377
+ const generateCommandSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => {
378
+ const filteredCommands = commands.filter(cmd => !cmd.isHidden)
379
+
380
+ if (!prefix) {
381
+ // Show all commands when prefix is empty (for single /)
382
+ return filteredCommands.map(cmd => ({
383
+ value: cmd.userFacingName(),
384
+ displayValue: `/${cmd.userFacingName()}`,
385
+ type: 'command' as const,
386
+ score: 100,
387
+ }))
388
+ }
389
+
390
+ return filteredCommands
391
+ .filter(cmd => {
392
+ const names = [cmd.userFacingName(), ...(cmd.aliases || [])]
393
+ return names.some(name => name.toLowerCase().startsWith(prefix.toLowerCase()))
394
+ })
395
+ .map(cmd => ({
396
+ value: cmd.userFacingName(),
397
+ displayValue: `/${cmd.userFacingName()}`,
398
+ type: 'command' as const,
399
+ score: 100 - prefix.length + (cmd.userFacingName().startsWith(prefix) ? 10 : 0),
400
+ }))
401
+ }, [commands])
402
+
403
+ // Clean Unix command scoring using fuzzy matcher
404
+ const calculateUnixCommandScore = useCallback((cmd: string, prefix: string): number => {
405
+ const result = matchCommands([cmd], prefix)
406
+ return result.length > 0 ? result[0].score : 0
407
+ }, [])
408
+
409
+ // Clean Unix command suggestions using fuzzy matcher with common commands boost
410
+ const generateUnixCommandSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => {
411
+ if (!prefix) return []
412
+
413
+ // Loading state
414
+ if (isLoadingCommands) {
415
+ return [{
416
+ value: 'loading...',
417
+ displayValue: `⏳ Loading system commands...`,
418
+ type: 'file' as const,
419
+ score: 0,
420
+ metadata: { isLoading: true }
421
+ }]
422
+ }
423
+
424
+ // IMPORTANT: Only use commands that exist on the system (intersection)
425
+ const commonCommands = getCommonSystemCommands(systemCommands)
426
+
427
+ // Deduplicate commands (in case of any duplicates)
428
+ const uniqueCommands = Array.from(new Set(commonCommands))
429
+
430
+ // Use fuzzy matcher ONLY on the unique intersection
431
+ const matches = matchCommands(uniqueCommands, prefix)
432
+
433
+ // Boost common commands
434
+ const boostedMatches = matches.map(match => {
435
+ const priority = getCommandPriority(match.command)
436
+ return {
437
+ ...match,
438
+ score: match.score + priority * 0.5 // Add priority boost
439
+ }
440
+ }).sort((a, b) => b.score - a.score)
441
+
442
+ // Limit results intelligently
443
+ let results = boostedMatches.slice(0, 8)
444
+
445
+ // If we have very high scores (900+), show fewer
446
+ const perfectMatches = boostedMatches.filter(m => m.score >= 900)
447
+ if (perfectMatches.length > 0 && perfectMatches.length <= 3) {
448
+ results = perfectMatches
449
+ }
450
+ // If we have good scores (100+), prefer them
451
+ else if (boostedMatches.length > 8) {
452
+ const goodMatches = boostedMatches.filter(m => m.score >= 100)
453
+ if (goodMatches.length <= 5) {
454
+ results = goodMatches
455
+ }
456
+ }
457
+
458
+ return results.map(item => ({
459
+ value: item.command,
460
+ displayValue: `$ ${item.command}`,
461
+ type: 'command' as const,
462
+ score: item.score,
463
+ metadata: { isUnixCommand: true }
464
+ }))
465
+ }, [systemCommands, isLoadingCommands])
466
+
467
+ // Agent suggestions cache
468
+ const [agentSuggestions, setAgentSuggestions] = useState<UnifiedSuggestion[]>([])
469
+
470
+ // Model suggestions cache
471
+ const [modelSuggestions, setModelSuggestions] = useState<UnifiedSuggestion[]>([])
472
+
473
+ // Load model suggestions
474
+ useEffect(() => {
475
+ try {
476
+ const modelManager = getModelManager()
477
+ const allModels = modelManager.getAllAvailableModelNames()
478
+
479
+ const suggestions = allModels.map(modelId => {
480
+ // Professional and clear description for expert model consultation
481
+ return {
482
+ value: `ask-${modelId}`,
483
+ displayValue: `🦜 ask-${modelId} :: Consult ${modelId} for expert opinion and specialized analysis`,
484
+ type: 'ask' as const,
485
+ score: 90, // Higher than agents - put ask-models on top
486
+ metadata: { modelId },
487
+ }
488
+ })
489
+
490
+ setModelSuggestions(suggestions)
491
+ } catch (error) {
492
+ console.warn('[useUnifiedCompletion] Failed to load models:', error)
493
+ // No fallback - rely on dynamic loading only
494
+ setModelSuggestions([])
495
+ }
496
+ }, [])
497
+
498
+ // Load agent suggestions on mount
499
+ useEffect(() => {
500
+ getActiveAgents().then(agents => {
501
+ // agents is an array of AgentConfig, not an object
502
+ const suggestions = agents.map(config => {
503
+ // 🧠 智能描述算法 - 适应性长度控制
504
+ let shortDesc = config.whenToUse
505
+
506
+ // 移除常见的冗余前缀,但保留核心内容
507
+ const prefixPatterns = [
508
+ /^Use this agent when you need (assistance with: )?/i,
509
+ /^Use PROACTIVELY (when|to) /i,
510
+ /^Specialized in /i,
511
+ /^Implementation specialist for /i,
512
+ /^Design validation specialist\.? Use PROACTIVELY to /i,
513
+ /^Task validation specialist\.? Use PROACTIVELY to /i,
514
+ /^Requirements validation specialist\.? Use PROACTIVELY to /i
515
+ ]
516
+
517
+ for (const pattern of prefixPatterns) {
518
+ shortDesc = shortDesc.replace(pattern, '')
519
+ }
520
+
521
+ // 🎯 精准断句算法:中英文句号感叹号优先 → 逗号 → 省略
522
+ const findSmartBreak = (text: string, maxLength: number) => {
523
+ if (text.length <= maxLength) return text
524
+
525
+ // 第一优先级:中英文句号、感叹号
526
+ const sentenceEndings = /[.!。!]/
527
+ const firstSentenceMatch = text.search(sentenceEndings)
528
+ if (firstSentenceMatch !== -1) {
529
+ const firstSentence = text.slice(0, firstSentenceMatch).trim()
530
+ if (firstSentence.length >= 5) {
531
+ return firstSentence
532
+ }
533
+ }
534
+
535
+ // 如果第一句过长,找逗号断句
536
+ if (text.length > maxLength) {
537
+ const commaEndings = /[,,]/
538
+ const commas = []
539
+ let match
540
+ const regex = new RegExp(commaEndings, 'g')
541
+ while ((match = regex.exec(text)) !== null) {
542
+ commas.push(match.index)
543
+ }
544
+
545
+ // 找最后一个在maxLength内的逗号
546
+ for (let i = commas.length - 1; i >= 0; i--) {
547
+ const commaPos = commas[i]
548
+ if (commaPos < maxLength) {
549
+ const clause = text.slice(0, commaPos).trim()
550
+ if (clause.length >= 5) {
551
+ return clause
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ // 最后选择:直接省略
558
+ return text.slice(0, maxLength) + '...'
559
+ }
560
+
561
+ shortDesc = findSmartBreak(shortDesc.trim(), 80) // 增加到80字符限制
562
+
563
+ // 如果处理后为空或太短,使用原始描述
564
+ if (!shortDesc || shortDesc.length < 5) {
565
+ shortDesc = findSmartBreak(config.whenToUse, 80)
566
+ }
567
+
568
+ return {
569
+ value: `run-agent-${config.agentType}`,
570
+ displayValue: `👤 run-agent-${config.agentType} :: ${shortDesc}`, // 人类图标 + run-agent前缀 + 简洁描述
571
+ type: 'agent' as const,
572
+ score: 85, // Lower than ask-models
573
+ metadata: config,
574
+ }
575
+ })
576
+ // Agents loaded successfully
577
+ setAgentSuggestions(suggestions)
578
+ }).catch((error) => {
579
+ console.warn('[useUnifiedCompletion] Failed to load agents:', error)
580
+ // No fallback - rely on dynamic loading only
581
+ setAgentSuggestions([])
582
+ })
583
+ }, [])
584
+
585
+ // Generate agent and model suggestions using fuzzy matching
586
+ const generateMentionSuggestions = useCallback((prefix: string): UnifiedSuggestion[] => {
587
+ // Combine agent and model suggestions
588
+ const allSuggestions = [...agentSuggestions, ...modelSuggestions]
589
+
590
+ if (!prefix) {
591
+ // Show all suggestions when prefix is empty (for single @)
592
+ return allSuggestions.sort((a, b) => {
593
+ // Ask models first (higher score), then agents
594
+ if (a.type === 'ask' && b.type === 'agent') return -1
595
+ if (a.type === 'agent' && b.type === 'ask') return 1
596
+ return b.score - a.score
597
+ })
598
+ }
599
+
600
+ // Use fuzzy matching for intelligent completion
601
+ const candidates = allSuggestions.map(s => s.value)
602
+ const matches = matchCommands(candidates, prefix)
603
+
604
+ // Create result mapping with fuzzy scores
605
+ const fuzzyResults = matches
606
+ .map(match => {
607
+ const suggestion = allSuggestions.find(s => s.value === match.command)!
608
+ return {
609
+ ...suggestion,
610
+ score: match.score // Use fuzzy match score instead of simple scoring
611
+ }
612
+ })
613
+ .sort((a, b) => {
614
+ // Ask models first (for equal scores), then agents
615
+ if (a.type === 'ask' && b.type === 'agent') return -1
616
+ if (a.type === 'agent' && b.type === 'ask') return 1
617
+ return b.score - a.score
618
+ })
619
+
620
+ return fuzzyResults
621
+ }, [agentSuggestions, modelSuggestions])
622
+
623
+ // Unix-style path completion - preserves user input semantics
624
+ const generateFileSuggestions = useCallback((prefix: string, isAtReference: boolean = false): UnifiedSuggestion[] => {
625
+ try {
626
+ const cwd = getCwd()
627
+
628
+ // Parse user input preserving original format
629
+ const userPath = prefix || '.'
630
+ const isAbsolutePath = userPath.startsWith('/')
631
+ const isHomePath = userPath.startsWith('~')
632
+
633
+ // Resolve search directory - but keep user's path format for output
634
+ let searchPath: string
635
+ if (isHomePath) {
636
+ searchPath = userPath.replace('~', process.env.HOME || '')
637
+ } else if (isAbsolutePath) {
638
+ searchPath = userPath
639
+ } else {
640
+ searchPath = resolve(cwd, userPath)
641
+ }
642
+
643
+ // Determine search directory and filename filter
644
+ // If path ends with '/', treat it as directory navigation
645
+ const endsWithSlash = userPath.endsWith('/')
646
+ const searchStat = existsSync(searchPath) ? statSync(searchPath) : null
647
+
648
+ let searchDir: string
649
+ let nameFilter: string
650
+
651
+ if (endsWithSlash || searchStat?.isDirectory()) {
652
+ // User is navigating into a directory or path ends with /
653
+ searchDir = searchPath
654
+ nameFilter = ''
655
+ } else {
656
+ // User might be typing a partial filename
657
+ searchDir = dirname(searchPath)
658
+ nameFilter = basename(searchPath)
659
+ }
660
+
661
+ if (!existsSync(searchDir)) return []
662
+
663
+ // Get directory entries with filter
664
+ const showHidden = nameFilter.startsWith('.') || userPath.includes('/.')
665
+ const entries = readdirSync(searchDir)
666
+ .filter(entry => {
667
+ // Filter hidden files unless user explicitly wants them
668
+ if (!showHidden && entry.startsWith('.')) return false
669
+ // Filter by name if there's a filter
670
+ if (nameFilter && !entry.toLowerCase().startsWith(nameFilter.toLowerCase())) return false
671
+ return true
672
+ })
673
+ .sort((a, b) => {
674
+ // Sort directories first, then files
675
+ const aPath = join(searchDir, a)
676
+ const bPath = join(searchDir, b)
677
+ const aIsDir = statSync(aPath).isDirectory()
678
+ const bIsDir = statSync(bPath).isDirectory()
679
+
680
+ if (aIsDir && !bIsDir) return -1
681
+ if (!aIsDir && bIsDir) return 1
682
+
683
+ // Within same type, sort alphabetically
684
+ return a.toLowerCase().localeCompare(b.toLowerCase())
685
+ })
686
+ .slice(0, 25) // Show more entries for better visibility
687
+
688
+ return entries.map(entry => {
689
+ const entryPath = join(searchDir, entry)
690
+ const isDir = statSync(entryPath).isDirectory()
691
+ const icon = isDir ? '📁' : '📄'
692
+
693
+ // Unix-style path building - preserve user's original path format
694
+ let value: string
695
+
696
+ if (userPath.includes('/')) {
697
+ // User typed path with separators - maintain structure
698
+ if (endsWithSlash) {
699
+ // User explicitly ended with / - they're inside the directory
700
+ value = userPath + entry + (isDir ? '/' : '')
701
+ } else if (searchStat?.isDirectory()) {
702
+ // Path is a directory but doesn't end with / - add separator
703
+ value = userPath + '/' + entry + (isDir ? '/' : '')
704
+ } else {
705
+ // User is completing a filename - replace basename
706
+ const userDir = userPath.includes('/') ? userPath.substring(0, userPath.lastIndexOf('/')) : ''
707
+ value = userDir ? userDir + '/' + entry + (isDir ? '/' : '') : entry + (isDir ? '/' : '')
708
+ }
709
+ } else {
710
+ // User typed simple name - check if it's an existing directory
711
+ if (searchStat?.isDirectory()) {
712
+ // Existing directory - navigate into it
713
+ value = userPath + '/' + entry + (isDir ? '/' : '')
714
+ } else {
715
+ // Simple completion at current level
716
+ value = entry + (isDir ? '/' : '')
717
+ }
718
+ }
719
+
720
+ return {
721
+ value,
722
+ displayValue: `${icon} ${entry}${isDir ? '/' : ''}`,
723
+ type: 'file' as const,
724
+ score: isDir ? 80 : 70,
725
+ }
726
+ })
727
+ } catch {
728
+ return []
729
+ }
730
+ }, [])
731
+
732
+ // Unified smart matching - single algorithm with different weights
733
+ const calculateMatchScore = useCallback((suggestion: UnifiedSuggestion, prefix: string): number => {
734
+ const lowerPrefix = prefix.toLowerCase()
735
+ const value = suggestion.value.toLowerCase()
736
+ const displayValue = suggestion.displayValue.toLowerCase()
737
+
738
+ let matchFound = false
739
+ let score = 0
740
+
741
+ // Check for actual matches first
742
+ if (value.startsWith(lowerPrefix)) {
743
+ matchFound = true
744
+ score = 100 // Highest priority
745
+ } else if (value.includes(lowerPrefix)) {
746
+ matchFound = true
747
+ score = 95
748
+ } else if (displayValue.includes(lowerPrefix)) {
749
+ matchFound = true
750
+ score = 90
751
+ } else {
752
+ // Word boundary matching for compound names like "general" -> "run-agent-general-purpose"
753
+ const words = value.split(/[-_]/)
754
+ if (words.some(word => word.startsWith(lowerPrefix))) {
755
+ matchFound = true
756
+ score = 93
757
+ } else {
758
+ // Acronym matching (last resort)
759
+ const acronym = words.map(word => word[0]).join('')
760
+ if (acronym.startsWith(lowerPrefix)) {
761
+ matchFound = true
762
+ score = 88
763
+ }
764
+ }
765
+ }
766
+
767
+ // Only return score if we found a match
768
+ if (!matchFound) return 0
769
+
770
+ // Type preferences (small bonus)
771
+ if (suggestion.type === 'ask') score += 2
772
+ if (suggestion.type === 'agent') score += 1
773
+
774
+ return score
775
+ }, [])
776
+
777
+ // Generate smart mention suggestions without data pollution
778
+ const generateSmartMentionSuggestions = useCallback((prefix: string, sourceContext: 'file' | 'agent' = 'file'): UnifiedSuggestion[] => {
779
+ if (!prefix || prefix.length < 2) return []
780
+
781
+ const allSuggestions = [...agentSuggestions, ...modelSuggestions]
782
+
783
+ return allSuggestions
784
+ .map(suggestion => {
785
+ const matchScore = calculateMatchScore(suggestion, prefix)
786
+ if (matchScore === 0) return null
787
+
788
+ // Clean transformation without data pollution
789
+ return {
790
+ ...suggestion,
791
+ score: matchScore,
792
+ isSmartMatch: true,
793
+ originalContext: sourceContext,
794
+ // Only modify display for clarity, keep value clean
795
+ displayValue: `🎯 ${suggestion.displayValue}`
796
+ }
797
+ })
798
+ .filter(Boolean)
799
+ .sort((a, b) => b.score - a.score)
800
+ .slice(0, 5)
801
+ }, [agentSuggestions, modelSuggestions, calculateMatchScore])
802
+
803
+ // Generate all suggestions based on context
804
+ const generateSuggestions = useCallback((context: CompletionContext): UnifiedSuggestion[] => {
805
+ switch (context.type) {
806
+ case 'command':
807
+ return generateCommandSuggestions(context.prefix)
808
+ case 'agent': {
809
+ // @ reference: combine mentions and files with clean priority
810
+ const mentionSuggestions = generateMentionSuggestions(context.prefix)
811
+ const fileSuggestions = generateFileSuggestions(context.prefix, true) // isAtReference=true
812
+
813
+ // Apply weights for @ context (agents/models should be prioritized but files visible)
814
+ const weightedSuggestions = [
815
+ ...mentionSuggestions.map(s => ({
816
+ ...s,
817
+ // In @ context, agents/models get high priority
818
+ weightedScore: s.score + 150
819
+ })),
820
+ ...fileSuggestions.map(s => ({
821
+ ...s,
822
+ // Files get lower priority but still visible
823
+ weightedScore: s.score + 10 // Small boost to ensure visibility
824
+ }))
825
+ ]
826
+
827
+ // Sort by weighted score - no artificial limits
828
+ return weightedSuggestions
829
+ .sort((a, b) => b.weightedScore - a.weightedScore)
830
+ .map(({ weightedScore, ...suggestion }) => suggestion)
831
+ // No limit or very generous limit (e.g., 30 items)
832
+ }
833
+ case 'file': {
834
+ // For normal input, try to match everything intelligently
835
+ const fileSuggestions = generateFileSuggestions(context.prefix, false)
836
+ const unixSuggestions = generateUnixCommandSuggestions(context.prefix)
837
+
838
+ // IMPORTANT: Also try to match agents and models WITHOUT requiring @
839
+ // This enables smart matching for inputs like "gp5", "daoqi", etc.
840
+ const mentionMatches = generateMentionSuggestions(context.prefix)
841
+ .map(s => ({
842
+ ...s,
843
+ isSmartMatch: true,
844
+ // Show that @ will be added when selected
845
+ displayValue: `\u2192 ${s.displayValue}` // Arrow to indicate it will transform
846
+ }))
847
+
848
+ // Apply source-based priority weights with special handling for exact matches
849
+ // Priority order: Exact Unix > Unix commands > agents/models > files
850
+ const weightedSuggestions = [
851
+ ...unixSuggestions.map(s => ({
852
+ ...s,
853
+ // Unix commands get boost, but exact matches get huge boost
854
+ sourceWeight: s.score >= 10000 ? 5000 : 200, // Exact match gets massive boost
855
+ weightedScore: s.score >= 10000 ? s.score + 5000 : s.score + 200
856
+ })),
857
+ ...mentionMatches.map(s => ({
858
+ ...s,
859
+ // Agents/models get medium priority boost (but less to avoid overriding exact Unix)
860
+ sourceWeight: 50,
861
+ weightedScore: s.score + 50
862
+ })),
863
+ ...fileSuggestions.map(s => ({
864
+ ...s,
865
+ // Files get no boost (baseline)
866
+ sourceWeight: 0,
867
+ weightedScore: s.score
868
+ }))
869
+ ]
870
+
871
+ // Sort by weighted score and deduplicate
872
+ const seen = new Set<string>()
873
+ const deduplicatedResults = weightedSuggestions
874
+ .sort((a, b) => b.weightedScore - a.weightedScore)
875
+ .filter(item => {
876
+ // Filter out duplicates based on value
877
+ if (seen.has(item.value)) return false
878
+ seen.add(item.value)
879
+ return true
880
+ })
881
+ .map(({ weightedScore, sourceWeight, ...suggestion }) => suggestion) // Remove weight fields
882
+ // No limit - show all relevant matches
883
+
884
+ return deduplicatedResults
885
+ }
886
+ default:
887
+ return []
888
+ }
889
+ }, [generateCommandSuggestions, generateMentionSuggestions, generateFileSuggestions, generateUnixCommandSuggestions, generateSmartMentionSuggestions])
890
+
891
+
892
+ // Complete with a suggestion - 支持万能@引用 + slash命令自动执行
893
+ const completeWith = useCallback((suggestion: UnifiedSuggestion, context: CompletionContext) => {
894
+ let completion: string
895
+
896
+ if (context.type === 'command') {
897
+ completion = `/${suggestion.value} `
898
+ } else if (context.type === 'agent') {
899
+ // 🚀 万能@引用:根据建议类型决定补全格式
900
+ if (suggestion.type === 'agent') {
901
+ completion = `@${suggestion.value} ` // 代理补全
902
+ } else if (suggestion.type === 'ask') {
903
+ completion = `@${suggestion.value} ` // Ask模型补全
904
+ } else {
905
+ // File reference in @mention context - no space for directories to allow expansion
906
+ const isDirectory = suggestion.value.endsWith('/')
907
+ completion = `@${suggestion.value}${isDirectory ? '' : ' '}` // 文件夹不加空格,文件加空格
908
+ }
909
+ } else {
910
+ // Regular file completion OR smart mention matching
911
+ if (suggestion.isSmartMatch) {
912
+ // Smart mention - add @ prefix and space
913
+ completion = `@${suggestion.value} `
914
+ } else {
915
+ // Regular file completion - no space for directories to allow expansion
916
+ const isDirectory = suggestion.value.endsWith('/')
917
+ completion = suggestion.value + (isDirectory ? '' : ' ')
918
+ }
919
+ }
920
+
921
+ // Special handling for absolute paths in file completion
922
+ // When completing an absolute path, we should replace the entire current word/path
923
+ let actualEndPos: number
924
+
925
+ if (context.type === 'file' && suggestion.value.startsWith('/') && !suggestion.isSmartMatch) {
926
+ // For absolute paths, find the end of the current path/word
927
+ let end = context.startPos
928
+ while (end < input.length && input[end] !== ' ' && input[end] !== '\n') {
929
+ end++
930
+ }
931
+ actualEndPos = end
932
+ } else {
933
+ // Original logic for other cases
934
+ const currentWord = input.slice(context.startPos)
935
+ const nextSpaceIndex = currentWord.indexOf(' ')
936
+ actualEndPos = nextSpaceIndex === -1 ? input.length : context.startPos + nextSpaceIndex
937
+ }
938
+
939
+ const newInput = input.slice(0, context.startPos) + completion + input.slice(actualEndPos)
940
+ onInputChange(newInput)
941
+ setCursorOffset(context.startPos + completion.length)
942
+
943
+ // Don't auto-execute slash commands - let user press Enter to submit
944
+ // This gives users a chance to add arguments or modify the command
945
+
946
+ // Completion applied
947
+ }, [input, onInputChange, setCursorOffset, onSubmit, commands])
948
+
949
+ // Partial complete to common prefix
950
+ const partialComplete = useCallback((prefix: string, context: CompletionContext) => {
951
+ const completion = context.type === 'command' ? `/${prefix}` :
952
+ context.type === 'agent' ? `@${prefix}` :
953
+ prefix
954
+
955
+ const newInput = input.slice(0, context.startPos) + completion + input.slice(context.endPos)
956
+ onInputChange(newInput)
957
+ setCursorOffset(context.startPos + completion.length)
958
+ }, [input, onInputChange, setCursorOffset])
959
+
960
+
961
+ // Handle Tab key - simplified and unified
962
+ useInput((input_str, key) => {
963
+ if (!key.tab || key.shift) return false
964
+
965
+ const context = getWordAtCursor()
966
+ if (!context) return false
967
+
968
+ // If menu is already showing, cycle through suggestions
969
+ if (state.isActive && state.suggestions.length > 0) {
970
+ const nextIndex = (state.selectedIndex + 1) % state.suggestions.length
971
+ const nextSuggestion = state.suggestions[nextIndex]
972
+
973
+ if (state.context) {
974
+ // Calculate proper word boundaries
975
+ const currentWord = input.slice(state.context.startPos)
976
+ const wordEnd = currentWord.search(/\s/)
977
+ const actualEndPos = wordEnd === -1
978
+ ? input.length
979
+ : state.context.startPos + wordEnd
980
+
981
+ // Apply appropriate prefix based on context type and suggestion type
982
+ let preview: string
983
+ if (state.context.type === 'command') {
984
+ preview = `/${nextSuggestion.value}`
985
+ } else if (state.context.type === 'agent') {
986
+ // For @mentions, always add @ prefix
987
+ preview = `@${nextSuggestion.value}`
988
+ } else if (nextSuggestion.isSmartMatch) {
989
+ // Smart match from normal input - add @ prefix
990
+ preview = `@${nextSuggestion.value}`
991
+ } else {
992
+ preview = nextSuggestion.value
993
+ }
994
+
995
+ // Apply preview
996
+ const newInput = input.slice(0, state.context.startPos) +
997
+ preview +
998
+ input.slice(actualEndPos)
999
+
1000
+ onInputChange(newInput)
1001
+ setCursorOffset(state.context.startPos + preview.length)
1002
+
1003
+ // Update state
1004
+ updateState({
1005
+ selectedIndex: nextIndex,
1006
+ preview: {
1007
+ isActive: true,
1008
+ originalInput: input,
1009
+ wordRange: [state.context.startPos, state.context.startPos + preview.length]
1010
+ }
1011
+ })
1012
+ }
1013
+ return true
1014
+ }
1015
+
1016
+ // Generate new suggestions
1017
+ const currentSuggestions = generateSuggestions(context)
1018
+
1019
+ if (currentSuggestions.length === 0) {
1020
+ return false // Let Tab pass through
1021
+ } else if (currentSuggestions.length === 1) {
1022
+ // Single match: complete immediately
1023
+ completeWith(currentSuggestions[0], context)
1024
+ return true
1025
+ } else {
1026
+ // Show menu and apply first suggestion
1027
+ activateCompletion(currentSuggestions, context)
1028
+
1029
+ // Immediately apply first suggestion as preview
1030
+ const firstSuggestion = currentSuggestions[0]
1031
+ const currentWord = input.slice(context.startPos)
1032
+ const wordEnd = currentWord.search(/\s/)
1033
+ const actualEndPos = wordEnd === -1
1034
+ ? input.length
1035
+ : context.startPos + wordEnd
1036
+
1037
+ let preview: string
1038
+ if (context.type === 'command') {
1039
+ preview = `/${firstSuggestion.value}`
1040
+ } else if (context.type === 'agent') {
1041
+ preview = `@${firstSuggestion.value}`
1042
+ } else if (firstSuggestion.isSmartMatch) {
1043
+ // Smart match from normal input - add @ prefix
1044
+ preview = `@${firstSuggestion.value}`
1045
+ } else {
1046
+ preview = firstSuggestion.value
1047
+ }
1048
+
1049
+ const newInput = input.slice(0, context.startPos) +
1050
+ preview +
1051
+ input.slice(actualEndPos)
1052
+
1053
+ onInputChange(newInput)
1054
+ setCursorOffset(context.startPos + preview.length)
1055
+
1056
+ updateState({
1057
+ preview: {
1058
+ isActive: true,
1059
+ originalInput: input,
1060
+ wordRange: [context.startPos, context.startPos + preview.length]
1061
+ }
1062
+ })
1063
+
1064
+ return true
1065
+ }
1066
+ })
1067
+
1068
+ // Handle navigation keys - simplified and unified
1069
+ useInput((_, key) => {
1070
+ // Enter key - confirm selection and end completion (always add space)
1071
+ if (key.return && state.isActive && state.suggestions.length > 0) {
1072
+ const selectedSuggestion = state.suggestions[state.selectedIndex]
1073
+ if (selectedSuggestion && state.context) {
1074
+ // For Enter key, always add space even for directories to indicate completion end
1075
+ let completion: string
1076
+
1077
+ if (state.context.type === 'command') {
1078
+ completion = `/${selectedSuggestion.value} `
1079
+ } else if (state.context.type === 'agent') {
1080
+ if (selectedSuggestion.type === 'agent') {
1081
+ completion = `@${selectedSuggestion.value} `
1082
+ } else if (selectedSuggestion.type === 'ask') {
1083
+ completion = `@${selectedSuggestion.value} `
1084
+ } else {
1085
+ // File reference in @mention context - always add space on Enter
1086
+ completion = `@${selectedSuggestion.value} `
1087
+ }
1088
+ } else if (selectedSuggestion.isSmartMatch) {
1089
+ // Smart match from normal input - add @ prefix
1090
+ completion = `@${selectedSuggestion.value} `
1091
+ } else {
1092
+ // Regular file completion - always add space on Enter
1093
+ completion = selectedSuggestion.value + ' '
1094
+ }
1095
+
1096
+ // Apply completion with forced space
1097
+ const currentWord = input.slice(state.context.startPos)
1098
+ const nextSpaceIndex = currentWord.indexOf(' ')
1099
+ const actualEndPos = nextSpaceIndex === -1 ? input.length : state.context.startPos + nextSpaceIndex
1100
+
1101
+ const newInput = input.slice(0, state.context.startPos) + completion + input.slice(actualEndPos)
1102
+ onInputChange(newInput)
1103
+ setCursorOffset(state.context.startPos + completion.length)
1104
+ }
1105
+ resetCompletion()
1106
+ return true
1107
+ }
1108
+
1109
+ if (!state.isActive || state.suggestions.length === 0) return false
1110
+
1111
+ // Arrow key navigation with preview
1112
+ const handleNavigation = (newIndex: number) => {
1113
+ const preview = state.suggestions[newIndex].value
1114
+
1115
+ if (state.preview?.isActive && state.context) {
1116
+ const newInput = input.slice(0, state.context.startPos) +
1117
+ preview +
1118
+ input.slice(state.preview.wordRange[1])
1119
+
1120
+ onInputChange(newInput)
1121
+ setCursorOffset(state.context.startPos + preview.length)
1122
+
1123
+ updateState({
1124
+ selectedIndex: newIndex,
1125
+ preview: {
1126
+ ...state.preview,
1127
+ wordRange: [state.context.startPos, state.context.startPos + preview.length]
1128
+ }
1129
+ })
1130
+ } else {
1131
+ updateState({ selectedIndex: newIndex })
1132
+ }
1133
+ }
1134
+
1135
+ if (key.downArrow) {
1136
+ const nextIndex = (state.selectedIndex + 1) % state.suggestions.length
1137
+ handleNavigation(nextIndex)
1138
+ return true
1139
+ }
1140
+
1141
+ if (key.upArrow) {
1142
+ const nextIndex = state.selectedIndex === 0
1143
+ ? state.suggestions.length - 1
1144
+ : state.selectedIndex - 1
1145
+ handleNavigation(nextIndex)
1146
+ return true
1147
+ }
1148
+
1149
+ // Space key - complete and potentially continue for directories
1150
+ if (key.space && state.isActive && state.suggestions.length > 0) {
1151
+ const selectedSuggestion = state.suggestions[state.selectedIndex]
1152
+ const isDirectory = selectedSuggestion.value.endsWith('/')
1153
+
1154
+ if (!state.context) return false
1155
+
1156
+ // Apply completion if needed
1157
+ const currentWordAtContext = input.slice(state.context.startPos,
1158
+ state.context.startPos + selectedSuggestion.value.length)
1159
+
1160
+ if (currentWordAtContext !== selectedSuggestion.value) {
1161
+ completeWith(selectedSuggestion, state.context)
1162
+ }
1163
+
1164
+ resetCompletion()
1165
+
1166
+ if (isDirectory) {
1167
+ // Continue completion for directories
1168
+ setTimeout(() => {
1169
+ const newContext = {
1170
+ ...state.context,
1171
+ prefix: selectedSuggestion.value,
1172
+ endPos: state.context.startPos + selectedSuggestion.value.length
1173
+ }
1174
+
1175
+ const newSuggestions = generateSuggestions(newContext)
1176
+
1177
+ if (newSuggestions.length > 0) {
1178
+ activateCompletion(newSuggestions, newContext)
1179
+ } else {
1180
+ updateState({
1181
+ emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}`
1182
+ })
1183
+ setTimeout(() => updateState({ emptyDirMessage: '' }), 3000)
1184
+ }
1185
+ }, 50)
1186
+ }
1187
+
1188
+ return true
1189
+ }
1190
+
1191
+ // Right arrow key - same as space but different semantics
1192
+ if (key.rightArrow) {
1193
+ const selectedSuggestion = state.suggestions[state.selectedIndex]
1194
+ const isDirectory = selectedSuggestion.value.endsWith('/')
1195
+
1196
+ if (!state.context) return false
1197
+
1198
+ // Apply completion
1199
+ const currentWordAtContext = input.slice(state.context.startPos,
1200
+ state.context.startPos + selectedSuggestion.value.length)
1201
+
1202
+ if (currentWordAtContext !== selectedSuggestion.value) {
1203
+ completeWith(selectedSuggestion, state.context)
1204
+ }
1205
+
1206
+ resetCompletion()
1207
+
1208
+ if (isDirectory) {
1209
+ // Continue for directories
1210
+ setTimeout(() => {
1211
+ const newContext = {
1212
+ ...state.context,
1213
+ prefix: selectedSuggestion.value,
1214
+ endPos: state.context.startPos + selectedSuggestion.value.length
1215
+ }
1216
+
1217
+ const newSuggestions = generateSuggestions(newContext)
1218
+
1219
+ if (newSuggestions.length > 0) {
1220
+ activateCompletion(newSuggestions, newContext)
1221
+ } else {
1222
+ updateState({
1223
+ emptyDirMessage: `Directory is empty: ${selectedSuggestion.value}`
1224
+ })
1225
+ setTimeout(() => updateState({ emptyDirMessage: '' }), 3000)
1226
+ }
1227
+ }, 50)
1228
+ }
1229
+
1230
+ return true
1231
+ }
1232
+
1233
+ if (key.escape) {
1234
+ // Restore original text if in preview mode
1235
+ if (state.preview?.isActive && state.context) {
1236
+ onInputChange(state.preview.originalInput)
1237
+ setCursorOffset(state.context.startPos + state.context.prefix.length)
1238
+ }
1239
+
1240
+ resetCompletion()
1241
+ return true
1242
+ }
1243
+
1244
+ return false
1245
+ })
1246
+
1247
+ // Handle delete/backspace keys - unified state management
1248
+ useInput((input_str, key) => {
1249
+ if (key.backspace || key.delete) {
1250
+ if (state.isActive) {
1251
+ resetCompletion()
1252
+ // Smart suppression based on input complexity
1253
+ const suppressionTime = input.length > 10 ? 200 : 100
1254
+ updateState({
1255
+ suppressUntil: Date.now() + suppressionTime
1256
+ })
1257
+ return true
1258
+ }
1259
+ }
1260
+ return false
1261
+ })
1262
+
1263
+ // Input tracking with ref to avoid infinite loops
1264
+ const lastInputRef = useRef('')
1265
+
1266
+ // Smart auto-triggering with cycle prevention
1267
+ useEffect(() => {
1268
+ // Prevent infinite loops by using ref
1269
+ if (lastInputRef.current === input) return
1270
+
1271
+ const inputLengthChange = Math.abs(input.length - lastInputRef.current.length)
1272
+ const isHistoryNavigation = (
1273
+ inputLengthChange > 10 || // Large content change
1274
+ (inputLengthChange > 5 && !input.includes(lastInputRef.current.slice(-5))) // Different content
1275
+ ) && input !== lastInputRef.current
1276
+
1277
+ // Update ref (no state update)
1278
+ lastInputRef.current = input
1279
+
1280
+ // Skip if in preview mode or suppressed
1281
+ if (state.preview?.isActive || Date.now() < state.suppressUntil) {
1282
+ return
1283
+ }
1284
+
1285
+ // Clear suggestions on history navigation
1286
+ if (isHistoryNavigation && state.isActive) {
1287
+ resetCompletion()
1288
+ return
1289
+ }
1290
+
1291
+ const context = getWordAtCursor()
1292
+
1293
+ if (context && shouldAutoTrigger(context)) {
1294
+ const newSuggestions = generateSuggestions(context)
1295
+
1296
+ if (newSuggestions.length === 0) {
1297
+ resetCompletion()
1298
+ } else if (newSuggestions.length === 1 && shouldAutoHideSingleMatch(newSuggestions[0], context)) {
1299
+ resetCompletion() // Perfect match - hide
1300
+ } else {
1301
+ activateCompletion(newSuggestions, context)
1302
+ }
1303
+ } else if (state.context) {
1304
+ // Check if context changed significantly
1305
+ const contextChanged = !context ||
1306
+ state.context.type !== context.type ||
1307
+ state.context.startPos !== context.startPos ||
1308
+ !context.prefix.startsWith(state.context.prefix)
1309
+
1310
+ if (contextChanged) {
1311
+ resetCompletion()
1312
+ }
1313
+ }
1314
+ }, [input, cursorOffset])
1315
+
1316
+ // Smart triggering - only when it makes sense
1317
+ const shouldAutoTrigger = useCallback((context: CompletionContext): boolean => {
1318
+ switch (context.type) {
1319
+ case 'command':
1320
+ // Trigger immediately for slash commands
1321
+ return true
1322
+ case 'agent':
1323
+ // Trigger immediately for agent references
1324
+ return true
1325
+ case 'file':
1326
+ // Be selective about file completion - avoid noise
1327
+ const prefix = context.prefix
1328
+
1329
+ // Always trigger for clear path patterns
1330
+ if (prefix.startsWith('./') || prefix.startsWith('../') ||
1331
+ prefix.startsWith('/') || prefix.startsWith('~') ||
1332
+ prefix.includes('/')) {
1333
+ return true
1334
+ }
1335
+
1336
+ // Trigger for single dot followed by something (like .g for .gitignore)
1337
+ if (prefix.startsWith('.') && prefix.length >= 2) {
1338
+ return true
1339
+ }
1340
+
1341
+ // Skip very short prefixes that are likely code
1342
+ return false
1343
+ default:
1344
+ return false
1345
+ }
1346
+ }, [])
1347
+
1348
+ // Helper function to determine if single suggestion should be auto-hidden
1349
+ const shouldAutoHideSingleMatch = useCallback((suggestion: UnifiedSuggestion, context: CompletionContext): boolean => {
1350
+ // Extract the actual typed input from context
1351
+ const currentInput = input.slice(context.startPos, context.endPos)
1352
+ // Check if should auto-hide single match
1353
+
1354
+ // For files: more intelligent matching
1355
+ if (context.type === 'file') {
1356
+ // Special case: if suggestion is a directory (ends with /), don't auto-hide
1357
+ // because user might want to continue navigating into it
1358
+ if (suggestion.value.endsWith('/')) {
1359
+ // Directory suggestion, keeping visible
1360
+ return false
1361
+ }
1362
+
1363
+ // Check exact match
1364
+ if (currentInput === suggestion.value) {
1365
+ // Exact match, hiding
1366
+ return true
1367
+ }
1368
+
1369
+ // Check if current input is a complete file path and suggestion is just the filename
1370
+ // e.g., currentInput: "src/tools/ThinkTool/ThinkTool.tsx", suggestion: "ThinkTool.tsx"
1371
+ if (currentInput.endsWith('/' + suggestion.value) || currentInput.endsWith(suggestion.value)) {
1372
+ // Path ends with suggestion, hiding
1373
+ return true
1374
+ }
1375
+
1376
+ return false
1377
+ }
1378
+
1379
+ // For commands: check if /prefix exactly matches /command
1380
+ if (context.type === 'command') {
1381
+ const fullCommand = `/${suggestion.value}`
1382
+ const matches = currentInput === fullCommand
1383
+ // Check command match
1384
+ return matches
1385
+ }
1386
+
1387
+ // For agents: check if @prefix exactly matches @agent-name
1388
+ if (context.type === 'agent') {
1389
+ const fullAgent = `@${suggestion.value}`
1390
+ const matches = currentInput === fullAgent
1391
+ // Check agent match
1392
+ return matches
1393
+ }
1394
+
1395
+ return false
1396
+ }, [input])
1397
+
1398
+ return {
1399
+ suggestions,
1400
+ selectedIndex,
1401
+ isActive,
1402
+ emptyDirMessage,
1403
+ }
1404
+ }