lonny-agent 0.1.2 → 0.1.9

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 (216) hide show
  1. package/.kilo/plans/1780064105789-mighty-pixel.md +171 -0
  2. package/.lonny/fix-new-command-session-cleanup.md +65 -0
  3. package/.lonny/tui-autocomplete-above.md +62 -0
  4. package/dist/agent/__tests__/compaction.test.js +6 -4
  5. package/dist/agent/__tests__/compaction.test.js.map +1 -1
  6. package/dist/agent/llm.d.ts +1 -1
  7. package/dist/agent/llm.d.ts.map +1 -1
  8. package/dist/agent/prompt-builder.d.ts.map +1 -1
  9. package/dist/agent/prompt-builder.js +26 -8
  10. package/dist/agent/prompt-builder.js.map +1 -1
  11. package/dist/agent/providers/anthropic.d.ts +1 -1
  12. package/dist/agent/providers/anthropic.d.ts.map +1 -1
  13. package/dist/agent/providers/anthropic.js +2 -2
  14. package/dist/agent/providers/anthropic.js.map +1 -1
  15. package/dist/agent/providers/google.d.ts +1 -1
  16. package/dist/agent/providers/google.d.ts.map +1 -1
  17. package/dist/agent/providers/google.js +2 -1
  18. package/dist/agent/providers/google.js.map +1 -1
  19. package/dist/agent/providers/ollama.d.ts +1 -1
  20. package/dist/agent/providers/ollama.d.ts.map +1 -1
  21. package/dist/agent/providers/ollama.js +2 -1
  22. package/dist/agent/providers/ollama.js.map +1 -1
  23. package/dist/agent/providers/openai.d.ts +1 -1
  24. package/dist/agent/providers/openai.d.ts.map +1 -1
  25. package/dist/agent/providers/openai.js +2 -2
  26. package/dist/agent/providers/openai.js.map +1 -1
  27. package/dist/agent/session.d.ts +1 -0
  28. package/dist/agent/session.d.ts.map +1 -1
  29. package/dist/agent/session.js +8 -2
  30. package/dist/agent/session.js.map +1 -1
  31. package/dist/pi-tui/autocomplete.d.ts +54 -0
  32. package/dist/pi-tui/autocomplete.d.ts.map +1 -0
  33. package/dist/pi-tui/autocomplete.js +636 -0
  34. package/dist/pi-tui/autocomplete.js.map +1 -0
  35. package/dist/pi-tui/components/box.d.ts +22 -0
  36. package/dist/pi-tui/components/box.d.ts.map +1 -0
  37. package/dist/pi-tui/components/box.js +104 -0
  38. package/dist/pi-tui/components/box.js.map +1 -0
  39. package/dist/pi-tui/components/cancellable-loader.d.ts +22 -0
  40. package/dist/pi-tui/components/cancellable-loader.d.ts.map +1 -0
  41. package/dist/pi-tui/components/cancellable-loader.js +35 -0
  42. package/dist/pi-tui/components/cancellable-loader.js.map +1 -0
  43. package/dist/pi-tui/components/editor.d.ts +249 -0
  44. package/dist/pi-tui/components/editor.d.ts.map +1 -0
  45. package/dist/pi-tui/components/editor.js +1883 -0
  46. package/dist/pi-tui/components/editor.js.map +1 -0
  47. package/dist/pi-tui/components/image.d.ts +28 -0
  48. package/dist/pi-tui/components/image.d.ts.map +1 -0
  49. package/dist/pi-tui/components/image.js +90 -0
  50. package/dist/pi-tui/components/image.js.map +1 -0
  51. package/dist/pi-tui/components/input.d.ts +37 -0
  52. package/dist/pi-tui/components/input.d.ts.map +1 -0
  53. package/dist/pi-tui/components/input.js +382 -0
  54. package/dist/pi-tui/components/input.js.map +1 -0
  55. package/dist/pi-tui/components/loader.d.ts +31 -0
  56. package/dist/pi-tui/components/loader.d.ts.map +1 -0
  57. package/dist/pi-tui/components/loader.js +70 -0
  58. package/dist/pi-tui/components/loader.js.map +1 -0
  59. package/dist/pi-tui/components/markdown.d.ts +96 -0
  60. package/dist/pi-tui/components/markdown.d.ts.map +1 -0
  61. package/dist/pi-tui/components/markdown.js +647 -0
  62. package/dist/pi-tui/components/markdown.js.map +1 -0
  63. package/dist/pi-tui/components/select-list.d.ts +50 -0
  64. package/dist/pi-tui/components/select-list.d.ts.map +1 -0
  65. package/dist/pi-tui/components/select-list.js +167 -0
  66. package/dist/pi-tui/components/select-list.js.map +1 -0
  67. package/dist/pi-tui/components/settings-list.d.ts +50 -0
  68. package/dist/pi-tui/components/settings-list.d.ts.map +1 -0
  69. package/dist/pi-tui/components/settings-list.js +189 -0
  70. package/dist/pi-tui/components/settings-list.js.map +1 -0
  71. package/dist/pi-tui/components/spacer.d.ts +12 -0
  72. package/dist/pi-tui/components/spacer.d.ts.map +1 -0
  73. package/dist/pi-tui/components/spacer.js +23 -0
  74. package/dist/pi-tui/components/spacer.js.map +1 -0
  75. package/dist/pi-tui/components/text.d.ts +19 -0
  76. package/dist/pi-tui/components/text.d.ts.map +1 -0
  77. package/dist/pi-tui/components/text.js +91 -0
  78. package/dist/pi-tui/components/text.js.map +1 -0
  79. package/dist/pi-tui/components/truncated-text.d.ts +13 -0
  80. package/dist/pi-tui/components/truncated-text.d.ts.map +1 -0
  81. package/dist/pi-tui/components/truncated-text.js +51 -0
  82. package/dist/pi-tui/components/truncated-text.js.map +1 -0
  83. package/dist/pi-tui/editor-component.d.ts +39 -0
  84. package/dist/pi-tui/editor-component.d.ts.map +1 -0
  85. package/dist/pi-tui/editor-component.js +2 -0
  86. package/dist/pi-tui/editor-component.js.map +1 -0
  87. package/dist/pi-tui/fuzzy.d.ts +16 -0
  88. package/dist/pi-tui/fuzzy.d.ts.map +1 -0
  89. package/dist/pi-tui/fuzzy.js +110 -0
  90. package/dist/pi-tui/fuzzy.js.map +1 -0
  91. package/dist/pi-tui/index.d.ts +23 -0
  92. package/dist/pi-tui/index.d.ts.map +1 -0
  93. package/dist/pi-tui/index.js +32 -0
  94. package/dist/pi-tui/index.js.map +1 -0
  95. package/dist/pi-tui/keybindings.d.ts +193 -0
  96. package/dist/pi-tui/keybindings.d.ts.map +1 -0
  97. package/dist/pi-tui/keybindings.js +174 -0
  98. package/dist/pi-tui/keybindings.js.map +1 -0
  99. package/dist/pi-tui/keys.d.ts +184 -0
  100. package/dist/pi-tui/keys.d.ts.map +1 -0
  101. package/dist/pi-tui/keys.js +1182 -0
  102. package/dist/pi-tui/keys.js.map +1 -0
  103. package/dist/pi-tui/kill-ring.d.ts +28 -0
  104. package/dist/pi-tui/kill-ring.d.ts.map +1 -0
  105. package/dist/pi-tui/kill-ring.js +44 -0
  106. package/dist/pi-tui/kill-ring.js.map +1 -0
  107. package/dist/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  108. package/dist/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  109. package/dist/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  110. package/dist/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  111. package/dist/pi-tui/native-modifiers.d.ts +3 -0
  112. package/dist/pi-tui/native-modifiers.d.ts.map +1 -0
  113. package/dist/pi-tui/native-modifiers.js +53 -0
  114. package/dist/pi-tui/native-modifiers.js.map +1 -0
  115. package/dist/pi-tui/stdin-buffer.d.ts +50 -0
  116. package/dist/pi-tui/stdin-buffer.d.ts.map +1 -0
  117. package/dist/pi-tui/stdin-buffer.js +361 -0
  118. package/dist/pi-tui/stdin-buffer.js.map +1 -0
  119. package/dist/pi-tui/terminal-image.d.ts +90 -0
  120. package/dist/pi-tui/terminal-image.d.ts.map +1 -0
  121. package/dist/pi-tui/terminal-image.js +343 -0
  122. package/dist/pi-tui/terminal-image.js.map +1 -0
  123. package/dist/pi-tui/terminal.d.ts +113 -0
  124. package/dist/pi-tui/terminal.d.ts.map +1 -0
  125. package/dist/pi-tui/terminal.js +478 -0
  126. package/dist/pi-tui/terminal.js.map +1 -0
  127. package/dist/pi-tui/tui.d.ts +227 -0
  128. package/dist/pi-tui/tui.d.ts.map +1 -0
  129. package/dist/pi-tui/tui.js +1091 -0
  130. package/dist/pi-tui/tui.js.map +1 -0
  131. package/dist/pi-tui/undo-stack.d.ts +17 -0
  132. package/dist/pi-tui/undo-stack.d.ts.map +1 -0
  133. package/dist/pi-tui/undo-stack.js +25 -0
  134. package/dist/pi-tui/undo-stack.js.map +1 -0
  135. package/dist/pi-tui/utils.d.ts +84 -0
  136. package/dist/pi-tui/utils.d.ts.map +1 -0
  137. package/dist/pi-tui/utils.js +1024 -0
  138. package/dist/pi-tui/utils.js.map +1 -0
  139. package/dist/pi-tui/word-navigation.d.ts +25 -0
  140. package/dist/pi-tui/word-navigation.d.ts.map +1 -0
  141. package/dist/pi-tui/word-navigation.js +98 -0
  142. package/dist/pi-tui/word-navigation.js.map +1 -0
  143. package/dist/tools/__tests__/edit.test.js +85 -0
  144. package/dist/tools/__tests__/edit.test.js.map +1 -1
  145. package/dist/tools/edit.d.ts.map +1 -1
  146. package/dist/tools/edit.js +23 -1
  147. package/dist/tools/edit.js.map +1 -1
  148. package/dist/tui/components.d.ts +1 -1
  149. package/dist/tui/components.d.ts.map +1 -1
  150. package/dist/tui/components.js +1 -1
  151. package/dist/tui/components.js.map +1 -1
  152. package/dist/tui/editor-patch.d.ts +13 -0
  153. package/dist/tui/editor-patch.d.ts.map +1 -0
  154. package/dist/tui/editor-patch.js +41 -0
  155. package/dist/tui/editor-patch.js.map +1 -0
  156. package/dist/tui/index.d.ts.map +1 -1
  157. package/dist/tui/index.js +15 -3
  158. package/dist/tui/index.js.map +1 -1
  159. package/dist/web/index.d.ts.map +1 -1
  160. package/dist/web/index.js +9 -2
  161. package/dist/web/index.js.map +1 -1
  162. package/dist/web/public/app.js +65 -4
  163. package/dist/web/public/style.css +49 -0
  164. package/dist/web/session-bridge.d.ts +0 -4
  165. package/dist/web/session-bridge.d.ts.map +1 -1
  166. package/dist/web/session-bridge.js +16 -2
  167. package/dist/web/session-bridge.js.map +1 -1
  168. package/package.json +5 -3
  169. package/src/agent/__tests__/compaction.test.ts +6 -4
  170. package/src/agent/llm.ts +5 -1
  171. package/src/agent/prompt-builder.ts +27 -8
  172. package/src/agent/providers/anthropic.ts +15 -8
  173. package/src/agent/providers/google.ts +6 -1
  174. package/src/agent/providers/ollama.ts +6 -1
  175. package/src/agent/providers/openai.ts +21 -12
  176. package/src/agent/session.ts +12 -2
  177. package/src/pi-tui/autocomplete.ts +808 -0
  178. package/src/pi-tui/components/box.ts +137 -0
  179. package/src/pi-tui/components/cancellable-loader.ts +40 -0
  180. package/src/pi-tui/components/editor.ts +2321 -0
  181. package/src/pi-tui/components/image.ts +130 -0
  182. package/src/pi-tui/components/input.ts +456 -0
  183. package/src/pi-tui/components/loader.ts +93 -0
  184. package/src/pi-tui/components/markdown.ts +839 -0
  185. package/src/pi-tui/components/select-list.ts +263 -0
  186. package/src/pi-tui/components/settings-list.ts +264 -0
  187. package/src/pi-tui/components/spacer.ts +28 -0
  188. package/src/pi-tui/components/text.ts +113 -0
  189. package/src/pi-tui/components/truncated-text.ts +65 -0
  190. package/src/pi-tui/editor-component.ts +74 -0
  191. package/src/pi-tui/fuzzy.ts +137 -0
  192. package/src/pi-tui/index.ts +115 -0
  193. package/src/pi-tui/keybindings.ts +245 -0
  194. package/src/pi-tui/keys.ts +1440 -0
  195. package/src/pi-tui/kill-ring.ts +46 -0
  196. package/src/pi-tui/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  197. package/src/pi-tui/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  198. package/src/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  199. package/src/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  200. package/src/pi-tui/native-modifiers.ts +65 -0
  201. package/src/pi-tui/stdin-buffer.ts +434 -0
  202. package/src/pi-tui/terminal-image.ts +469 -0
  203. package/src/pi-tui/terminal.ts +594 -0
  204. package/src/pi-tui/tui.ts +1376 -0
  205. package/src/pi-tui/undo-stack.ts +28 -0
  206. package/src/pi-tui/utils.ts +1176 -0
  207. package/src/pi-tui/word-navigation.ts +127 -0
  208. package/src/tools/__tests__/edit.test.ts +91 -0
  209. package/src/tools/edit.ts +27 -1
  210. package/src/tui/components.ts +2 -2
  211. package/src/tui/index.ts +24 -10
  212. package/src/web/index.ts +9 -2
  213. package/src/web/public/app.js +65 -4
  214. package/src/web/public/style.css +49 -0
  215. package/src/web/session-bridge.ts +13 -2
  216. package/tsconfig.json +2 -2
@@ -0,0 +1,808 @@
1
+ import { spawn } from 'child_process'
2
+ import { readdirSync, statSync } from 'fs'
3
+ import { homedir } from 'os'
4
+ import { basename, dirname, join } from 'path'
5
+ import { fuzzyFilter } from './fuzzy.js'
6
+
7
+ const PATH_DELIMITERS = new Set([' ', '\t', '"', "'", '='])
8
+
9
+ function toDisplayPath(value: string): string {
10
+ return value.replace(/\\/g, '/')
11
+ }
12
+
13
+ function escapeRegex(value: string): string {
14
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
15
+ }
16
+
17
+ function buildFdPathQuery(query: string): string {
18
+ const normalized = toDisplayPath(query)
19
+ if (!normalized.includes('/')) {
20
+ return normalized
21
+ }
22
+
23
+ const hasTrailingSeparator = normalized.endsWith('/')
24
+ const trimmed = normalized.replace(/^\/+|\/+$/g, '')
25
+ if (!trimmed) {
26
+ return normalized
27
+ }
28
+
29
+ const separatorPattern = '[\\\\/]'
30
+ const segments = trimmed
31
+ .split('/')
32
+ .filter(Boolean)
33
+ .map(segment => escapeRegex(segment))
34
+ if (segments.length === 0) {
35
+ return normalized
36
+ }
37
+
38
+ let pattern = segments.join(separatorPattern)
39
+ if (hasTrailingSeparator) {
40
+ pattern += separatorPattern
41
+ }
42
+ return pattern
43
+ }
44
+
45
+ function findLastDelimiter(text: string): number {
46
+ for (let i = text.length - 1; i >= 0; i -= 1) {
47
+ if (PATH_DELIMITERS.has(text[i] ?? '')) {
48
+ return i
49
+ }
50
+ }
51
+ return -1
52
+ }
53
+
54
+ function findUnclosedQuoteStart(text: string): number | null {
55
+ let inQuotes = false
56
+ let quoteStart = -1
57
+
58
+ for (let i = 0; i < text.length; i += 1) {
59
+ if (text[i] === '"') {
60
+ inQuotes = !inQuotes
61
+ if (inQuotes) {
62
+ quoteStart = i
63
+ }
64
+ }
65
+ }
66
+
67
+ return inQuotes ? quoteStart : null
68
+ }
69
+
70
+ function isTokenStart(text: string, index: number): boolean {
71
+ return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? '')
72
+ }
73
+
74
+ function extractQuotedPrefix(text: string): string | null {
75
+ const quoteStart = findUnclosedQuoteStart(text)
76
+ if (quoteStart === null) {
77
+ return null
78
+ }
79
+
80
+ if (quoteStart > 0 && text[quoteStart - 1] === '@') {
81
+ if (!isTokenStart(text, quoteStart - 1)) {
82
+ return null
83
+ }
84
+ return text.slice(quoteStart - 1)
85
+ }
86
+
87
+ if (!isTokenStart(text, quoteStart)) {
88
+ return null
89
+ }
90
+
91
+ return text.slice(quoteStart)
92
+ }
93
+
94
+ function parsePathPrefix(prefix: string): {
95
+ rawPrefix: string
96
+ isAtPrefix: boolean
97
+ isQuotedPrefix: boolean
98
+ } {
99
+ if (prefix.startsWith('@"')) {
100
+ return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true }
101
+ }
102
+ if (prefix.startsWith('"')) {
103
+ return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true }
104
+ }
105
+ if (prefix.startsWith('@')) {
106
+ return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false }
107
+ }
108
+ return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false }
109
+ }
110
+
111
+ function buildCompletionValue(
112
+ path: string,
113
+ options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
114
+ ): string {
115
+ const needsQuotes = options.isQuotedPrefix || path.includes(' ')
116
+ const prefix = options.isAtPrefix ? '@' : ''
117
+
118
+ if (!needsQuotes) {
119
+ return `${prefix}${path}`
120
+ }
121
+
122
+ const openQuote = `${prefix}"`
123
+ const closeQuote = '"'
124
+ return `${openQuote}${path}${closeQuote}`
125
+ }
126
+
127
+ // Use fd to walk directory tree (fast, respects .gitignore)
128
+ async function walkDirectoryWithFd(
129
+ baseDir: string,
130
+ fdPath: string,
131
+ query: string,
132
+ maxResults: number,
133
+ signal: AbortSignal,
134
+ ): Promise<Array<{ path: string; isDirectory: boolean }>> {
135
+ const args = [
136
+ '--base-directory',
137
+ baseDir,
138
+ '--max-results',
139
+ String(maxResults),
140
+ '--type',
141
+ 'f',
142
+ '--type',
143
+ 'd',
144
+ '--follow',
145
+ '--hidden',
146
+ '--exclude',
147
+ '.git',
148
+ '--exclude',
149
+ '.git/*',
150
+ '--exclude',
151
+ '.git/**',
152
+ ]
153
+
154
+ if (toDisplayPath(query).includes('/')) {
155
+ args.push('--full-path')
156
+ }
157
+
158
+ if (query) {
159
+ args.push(buildFdPathQuery(query))
160
+ }
161
+
162
+ return await new Promise(resolve => {
163
+ if (signal.aborted) {
164
+ resolve([])
165
+ return
166
+ }
167
+
168
+ const child = spawn(fdPath, args, {
169
+ stdio: ['ignore', 'pipe', 'pipe'],
170
+ })
171
+ let stdout = ''
172
+ let resolved = false
173
+
174
+ const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
175
+ if (resolved) return
176
+ resolved = true
177
+ signal.removeEventListener('abort', onAbort)
178
+ resolve(results)
179
+ }
180
+
181
+ const onAbort = () => {
182
+ if (child.exitCode === null) {
183
+ child.kill('SIGKILL')
184
+ }
185
+ }
186
+
187
+ signal.addEventListener('abort', onAbort, { once: true })
188
+ child.stdout.setEncoding('utf-8')
189
+ child.stdout.on('data', (chunk: string) => {
190
+ stdout += chunk
191
+ })
192
+ child.on('error', () => {
193
+ finish([])
194
+ })
195
+ child.on('close', code => {
196
+ if (signal.aborted || code !== 0 || !stdout) {
197
+ finish([])
198
+ return
199
+ }
200
+
201
+ const lines = stdout.trim().split('\n').filter(Boolean)
202
+ const results: Array<{ path: string; isDirectory: boolean }> = []
203
+
204
+ for (const line of lines) {
205
+ const displayLine = toDisplayPath(line)
206
+ const hasTrailingSeparator = displayLine.endsWith('/')
207
+ const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine
208
+ if (
209
+ normalizedPath === '.git' ||
210
+ normalizedPath.startsWith('.git/') ||
211
+ normalizedPath.includes('/.git/')
212
+ ) {
213
+ continue
214
+ }
215
+
216
+ results.push({
217
+ path: displayLine,
218
+ isDirectory: hasTrailingSeparator,
219
+ })
220
+ }
221
+
222
+ finish(results)
223
+ })
224
+ })
225
+ }
226
+
227
+ export interface AutocompleteItem {
228
+ value: string
229
+ label: string
230
+ description?: string
231
+ }
232
+
233
+ type Awaitable<T> = T | Promise<T>
234
+
235
+ export interface SlashCommand {
236
+ name: string
237
+ description?: string
238
+ argumentHint?: string
239
+ // Function to get argument completions for this command
240
+ // Returns null if no argument completion is available
241
+ getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>
242
+ }
243
+
244
+ export interface AutocompleteSuggestions {
245
+ items: AutocompleteItem[]
246
+ prefix: string // What we're matching against (e.g., "/" or "src/")
247
+ }
248
+
249
+ export interface AutocompleteProvider {
250
+ // Get autocomplete suggestions for current text/cursor position
251
+ // Returns null if no suggestions available
252
+ getSuggestions(
253
+ lines: string[],
254
+ cursorLine: number,
255
+ cursorCol: number,
256
+ options: { signal: AbortSignal; force?: boolean },
257
+ ): Promise<AutocompleteSuggestions | null>
258
+
259
+ // Apply the selected item
260
+ // Returns the new text and cursor position
261
+ applyCompletion(
262
+ lines: string[],
263
+ cursorLine: number,
264
+ cursorCol: number,
265
+ item: AutocompleteItem,
266
+ prefix: string,
267
+ ): {
268
+ lines: string[]
269
+ cursorLine: number
270
+ cursorCol: number
271
+ }
272
+
273
+ // Check if file completion should trigger for explicit Tab completion
274
+ shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean
275
+ }
276
+
277
+ // Combined provider that handles both slash commands and file paths
278
+ export class CombinedAutocompleteProvider implements AutocompleteProvider {
279
+ private commands: (SlashCommand | AutocompleteItem)[]
280
+ private basePath: string
281
+ private fdPath: string | null
282
+
283
+ constructor(
284
+ commands: (SlashCommand | AutocompleteItem)[] = [],
285
+ basePath: string,
286
+ fdPath: string | null = null,
287
+ ) {
288
+ this.commands = commands
289
+ this.basePath = basePath
290
+ this.fdPath = fdPath
291
+ }
292
+
293
+ async getSuggestions(
294
+ lines: string[],
295
+ cursorLine: number,
296
+ cursorCol: number,
297
+ options: { signal: AbortSignal; force?: boolean },
298
+ ): Promise<AutocompleteSuggestions | null> {
299
+ const currentLine = lines[cursorLine] || ''
300
+ const textBeforeCursor = currentLine.slice(0, cursorCol)
301
+
302
+ const atPrefix = this.extractAtPrefix(textBeforeCursor)
303
+ if (atPrefix) {
304
+ const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix)
305
+ const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
306
+ isQuotedPrefix,
307
+ signal: options.signal,
308
+ })
309
+ if (suggestions.length === 0) return null
310
+
311
+ return {
312
+ items: suggestions,
313
+ prefix: atPrefix,
314
+ }
315
+ }
316
+
317
+ if (!options.force && textBeforeCursor.startsWith('/')) {
318
+ const spaceIndex = textBeforeCursor.indexOf(' ')
319
+
320
+ if (spaceIndex === -1) {
321
+ const prefix = textBeforeCursor.slice(1)
322
+ const commandItems = this.commands.map(cmd => {
323
+ const name = 'name' in cmd ? cmd.name : cmd.value
324
+ const hint = 'argumentHint' in cmd && cmd.argumentHint ? cmd.argumentHint : undefined
325
+ const desc = cmd.description ?? ''
326
+ const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc
327
+ return {
328
+ name,
329
+ label: name,
330
+ description: fullDesc || undefined,
331
+ }
332
+ })
333
+
334
+ const filtered = fuzzyFilter(commandItems, prefix, item => item.name).map(item => ({
335
+ value: item.name,
336
+ label: item.label,
337
+ ...(item.description && { description: item.description }),
338
+ }))
339
+
340
+ if (filtered.length === 0) return null
341
+
342
+ return {
343
+ items: filtered,
344
+ prefix: textBeforeCursor,
345
+ }
346
+ }
347
+
348
+ const commandName = textBeforeCursor.slice(1, spaceIndex)
349
+ const argumentText = textBeforeCursor.slice(spaceIndex + 1)
350
+
351
+ const command = this.commands.find(cmd => {
352
+ const name = 'name' in cmd ? cmd.name : cmd.value
353
+ return name === commandName
354
+ })
355
+ if (!command || !('getArgumentCompletions' in command) || !command.getArgumentCompletions) {
356
+ return null
357
+ }
358
+
359
+ const argumentSuggestions = await command.getArgumentCompletions(argumentText)
360
+ if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
361
+ return null
362
+ }
363
+
364
+ return {
365
+ items: argumentSuggestions,
366
+ prefix: argumentText,
367
+ }
368
+ }
369
+
370
+ const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false)
371
+ if (pathMatch === null) {
372
+ return null
373
+ }
374
+
375
+ const suggestions = this.getFileSuggestions(pathMatch)
376
+ if (suggestions.length === 0) return null
377
+
378
+ return {
379
+ items: suggestions,
380
+ prefix: pathMatch,
381
+ }
382
+ }
383
+
384
+ applyCompletion(
385
+ lines: string[],
386
+ cursorLine: number,
387
+ cursorCol: number,
388
+ item: AutocompleteItem,
389
+ prefix: string,
390
+ ): { lines: string[]; cursorLine: number; cursorCol: number } {
391
+ const currentLine = lines[cursorLine] || ''
392
+ const beforePrefix = currentLine.slice(0, cursorCol - prefix.length)
393
+ const afterCursor = currentLine.slice(cursorCol)
394
+ const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"')
395
+ const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"')
396
+ const hasTrailingQuoteInItem = item.value.endsWith('"')
397
+ const adjustedAfterCursor =
398
+ isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor
399
+ ? afterCursor.slice(1)
400
+ : afterCursor
401
+
402
+ // Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
403
+ // Slash commands are at the start of the line and don't contain path separators after the first /
404
+ const isSlashCommand =
405
+ prefix.startsWith('/') && beforePrefix.trim() === '' && !prefix.slice(1).includes('/')
406
+ if (isSlashCommand) {
407
+ // This is a command name completion
408
+ const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`
409
+ const newLines = [...lines]
410
+ newLines[cursorLine] = newLine
411
+
412
+ return {
413
+ lines: newLines,
414
+ cursorLine,
415
+ cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
416
+ }
417
+ }
418
+
419
+ // Check if we're completing a file attachment (prefix starts with "@")
420
+ if (prefix.startsWith('@')) {
421
+ // This is a file attachment completion
422
+ // Don't add space after directories so user can continue autocompleting
423
+ const isDirectory = item.label.endsWith('/')
424
+ const suffix = isDirectory ? '' : ' '
425
+ const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`
426
+ const newLines = [...lines]
427
+ newLines[cursorLine] = newLine
428
+
429
+ const hasTrailingQuote = item.value.endsWith('"')
430
+ const cursorOffset =
431
+ isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length
432
+
433
+ return {
434
+ lines: newLines,
435
+ cursorLine,
436
+ cursorCol: beforePrefix.length + cursorOffset + suffix.length,
437
+ }
438
+ }
439
+
440
+ // Check if we're in a slash command context (beforePrefix contains "/command ")
441
+ const textBeforeCursor = currentLine.slice(0, cursorCol)
442
+ if (textBeforeCursor.includes('/') && textBeforeCursor.includes(' ')) {
443
+ // This is likely a command argument completion
444
+ const newLine = beforePrefix + item.value + adjustedAfterCursor
445
+ const newLines = [...lines]
446
+ newLines[cursorLine] = newLine
447
+
448
+ const isDirectory = item.label.endsWith('/')
449
+ const hasTrailingQuote = item.value.endsWith('"')
450
+ const cursorOffset =
451
+ isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length
452
+
453
+ return {
454
+ lines: newLines,
455
+ cursorLine,
456
+ cursorCol: beforePrefix.length + cursorOffset,
457
+ }
458
+ }
459
+
460
+ // For file paths, complete the path
461
+ const newLine = beforePrefix + item.value + adjustedAfterCursor
462
+ const newLines = [...lines]
463
+ newLines[cursorLine] = newLine
464
+
465
+ const isDirectory = item.label.endsWith('/')
466
+ const hasTrailingQuote = item.value.endsWith('"')
467
+ const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length
468
+
469
+ return {
470
+ lines: newLines,
471
+ cursorLine,
472
+ cursorCol: beforePrefix.length + cursorOffset,
473
+ }
474
+ }
475
+
476
+ // Extract @ prefix for fuzzy file suggestions
477
+ private extractAtPrefix(text: string): string | null {
478
+ const quotedPrefix = extractQuotedPrefix(text)
479
+ if (quotedPrefix?.startsWith('@"')) {
480
+ return quotedPrefix
481
+ }
482
+
483
+ const lastDelimiterIndex = findLastDelimiter(text)
484
+ const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1
485
+
486
+ if (text[tokenStart] === '@') {
487
+ return text.slice(tokenStart)
488
+ }
489
+
490
+ return null
491
+ }
492
+
493
+ // Extract a path-like prefix from the text before cursor
494
+ private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
495
+ const quotedPrefix = extractQuotedPrefix(text)
496
+ if (quotedPrefix) {
497
+ return quotedPrefix
498
+ }
499
+
500
+ const lastDelimiterIndex = findLastDelimiter(text)
501
+ const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1)
502
+
503
+ // For forced extraction (Tab key), always return something
504
+ if (forceExtract) {
505
+ return pathPrefix
506
+ }
507
+
508
+ // For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
509
+ // Only return empty string if the text looks like it's starting a path context
510
+ if (pathPrefix.includes('/') || pathPrefix.startsWith('.') || pathPrefix.startsWith('~/')) {
511
+ return pathPrefix
512
+ }
513
+
514
+ // Return empty string only after a space (not for completely empty text)
515
+ // Empty text should not trigger file suggestions - that's for forced Tab completion
516
+ if (pathPrefix === '' && text.endsWith(' ')) {
517
+ return pathPrefix
518
+ }
519
+
520
+ return null
521
+ }
522
+
523
+ // Expand home directory (~/) to actual home path
524
+ private expandHomePath(path: string): string {
525
+ if (path.startsWith('~/')) {
526
+ const expandedPath = join(homedir(), path.slice(2))
527
+ // Preserve trailing slash if original path had one
528
+ return path.endsWith('/') && !expandedPath.endsWith('/') ? `${expandedPath}/` : expandedPath
529
+ } else if (path === '~') {
530
+ return homedir()
531
+ }
532
+ return path
533
+ }
534
+
535
+ private resolveScopedFuzzyQuery(
536
+ rawQuery: string,
537
+ ): { baseDir: string; query: string; displayBase: string } | null {
538
+ const normalizedQuery = toDisplayPath(rawQuery)
539
+ const slashIndex = normalizedQuery.lastIndexOf('/')
540
+ if (slashIndex === -1) {
541
+ return null
542
+ }
543
+
544
+ const displayBase = normalizedQuery.slice(0, slashIndex + 1)
545
+ const query = normalizedQuery.slice(slashIndex + 1)
546
+
547
+ let baseDir: string
548
+ if (displayBase.startsWith('~/')) {
549
+ baseDir = this.expandHomePath(displayBase)
550
+ } else if (displayBase.startsWith('/')) {
551
+ baseDir = displayBase
552
+ } else {
553
+ baseDir = join(this.basePath, displayBase)
554
+ }
555
+
556
+ try {
557
+ if (!statSync(baseDir).isDirectory()) {
558
+ return null
559
+ }
560
+ } catch {
561
+ return null
562
+ }
563
+
564
+ return { baseDir, query, displayBase }
565
+ }
566
+
567
+ private scopedPathForDisplay(displayBase: string, relativePath: string): string {
568
+ const normalizedRelativePath = toDisplayPath(relativePath)
569
+ if (displayBase === '/') {
570
+ return `/${normalizedRelativePath}`
571
+ }
572
+ return `${toDisplayPath(displayBase)}${normalizedRelativePath}`
573
+ }
574
+
575
+ // Get file/directory suggestions for a given path prefix
576
+ private getFileSuggestions(prefix: string): AutocompleteItem[] {
577
+ try {
578
+ let searchDir: string
579
+ let searchPrefix: string
580
+ const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix)
581
+ let expandedPrefix = rawPrefix
582
+
583
+ // Handle home directory expansion
584
+ if (expandedPrefix.startsWith('~')) {
585
+ expandedPrefix = this.expandHomePath(expandedPrefix)
586
+ }
587
+
588
+ const isRootPrefix =
589
+ rawPrefix === '' ||
590
+ rawPrefix === './' ||
591
+ rawPrefix === '../' ||
592
+ rawPrefix === '~' ||
593
+ rawPrefix === '~/' ||
594
+ rawPrefix === '/' ||
595
+ (isAtPrefix && rawPrefix === '')
596
+
597
+ if (isRootPrefix) {
598
+ // Complete from specified position
599
+ if (rawPrefix.startsWith('~') || expandedPrefix.startsWith('/')) {
600
+ searchDir = expandedPrefix
601
+ } else {
602
+ searchDir = join(this.basePath, expandedPrefix)
603
+ }
604
+ searchPrefix = ''
605
+ } else if (rawPrefix.endsWith('/')) {
606
+ // If prefix ends with /, show contents of that directory
607
+ if (rawPrefix.startsWith('~') || expandedPrefix.startsWith('/')) {
608
+ searchDir = expandedPrefix
609
+ } else {
610
+ searchDir = join(this.basePath, expandedPrefix)
611
+ }
612
+ searchPrefix = ''
613
+ } else {
614
+ // Split into directory and file prefix
615
+ const dir = dirname(expandedPrefix)
616
+ const file = basename(expandedPrefix)
617
+ if (rawPrefix.startsWith('~') || expandedPrefix.startsWith('/')) {
618
+ searchDir = dir
619
+ } else {
620
+ searchDir = join(this.basePath, dir)
621
+ }
622
+ searchPrefix = file
623
+ }
624
+
625
+ const entries = readdirSync(searchDir, { withFileTypes: true })
626
+ const suggestions: AutocompleteItem[] = []
627
+
628
+ for (const entry of entries) {
629
+ if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
630
+ continue
631
+ }
632
+
633
+ // Check if entry is a directory (or a symlink pointing to a directory)
634
+ let isDirectory = entry.isDirectory()
635
+ if (!isDirectory && entry.isSymbolicLink()) {
636
+ try {
637
+ const fullPath = join(searchDir, entry.name)
638
+ isDirectory = statSync(fullPath).isDirectory()
639
+ } catch {
640
+ // Broken symlink or permission error - treat as file
641
+ }
642
+ }
643
+
644
+ let relativePath: string
645
+ const name = entry.name
646
+ const displayPrefix = rawPrefix
647
+
648
+ if (displayPrefix.endsWith('/')) {
649
+ // If prefix ends with /, append entry to the prefix
650
+ relativePath = displayPrefix + name
651
+ } else if (displayPrefix.includes('/') || displayPrefix.includes('\\')) {
652
+ // Preserve ~/ format for home directory paths
653
+ if (displayPrefix.startsWith('~/')) {
654
+ const homeRelativeDir = displayPrefix.slice(2) // Remove ~/
655
+ const dir = dirname(homeRelativeDir)
656
+ relativePath = `~/${dir === '.' ? name : join(dir, name)}`
657
+ } else if (displayPrefix.startsWith('/')) {
658
+ // Absolute path - construct properly
659
+ const dir = dirname(displayPrefix)
660
+ if (dir === '/') {
661
+ relativePath = `/${name}`
662
+ } else {
663
+ relativePath = `${dir}/${name}`
664
+ }
665
+ } else {
666
+ relativePath = join(dirname(displayPrefix), name)
667
+ // path.join normalizes away ./ prefix, preserve it
668
+ if (displayPrefix.startsWith('./') && !relativePath.startsWith('./')) {
669
+ relativePath = `./${relativePath}`
670
+ }
671
+ }
672
+ } else {
673
+ // For standalone entries, preserve ~/ if original prefix was ~/
674
+ if (displayPrefix.startsWith('~')) {
675
+ relativePath = `~/${name}`
676
+ } else {
677
+ relativePath = name
678
+ }
679
+ }
680
+
681
+ relativePath = toDisplayPath(relativePath)
682
+ const pathValue = isDirectory ? `${relativePath}/` : relativePath
683
+ const value = buildCompletionValue(pathValue, {
684
+ isDirectory,
685
+ isAtPrefix,
686
+ isQuotedPrefix,
687
+ })
688
+
689
+ suggestions.push({
690
+ value,
691
+ label: name + (isDirectory ? '/' : ''),
692
+ })
693
+ }
694
+
695
+ // Sort directories first, then alphabetically
696
+ suggestions.sort((a, b) => {
697
+ const aIsDir = a.value.endsWith('/')
698
+ const bIsDir = b.value.endsWith('/')
699
+ if (aIsDir && !bIsDir) return -1
700
+ if (!aIsDir && bIsDir) return 1
701
+ return a.label.localeCompare(b.label)
702
+ })
703
+
704
+ return suggestions
705
+ } catch (_e) {
706
+ // Directory doesn't exist or not accessible
707
+ return []
708
+ }
709
+ }
710
+
711
+ // Score an entry against the query (higher = better match)
712
+ // isDirectory adds bonus to prioritize folders
713
+ private scoreEntry(filePath: string, query: string, isDirectory: boolean): number {
714
+ const fileName = basename(filePath)
715
+ const lowerFileName = fileName.toLowerCase()
716
+ const lowerQuery = query.toLowerCase()
717
+
718
+ let score = 0
719
+
720
+ // Exact filename match (highest)
721
+ if (lowerFileName === lowerQuery) score = 100
722
+ // Filename starts with query
723
+ else if (lowerFileName.startsWith(lowerQuery)) score = 80
724
+ // Substring match in filename
725
+ else if (lowerFileName.includes(lowerQuery)) score = 50
726
+ // Substring match in full path
727
+ else if (filePath.toLowerCase().includes(lowerQuery)) score = 30
728
+
729
+ // Directories get a bonus to appear first
730
+ if (isDirectory && score > 0) score += 10
731
+
732
+ return score
733
+ }
734
+
735
+ // Fuzzy file search using fd (fast, respects .gitignore)
736
+ private async getFuzzyFileSuggestions(
737
+ query: string,
738
+ options: { isQuotedPrefix: boolean; signal: AbortSignal },
739
+ ): Promise<AutocompleteItem[]> {
740
+ if (!this.fdPath || options.signal.aborted) {
741
+ return []
742
+ }
743
+
744
+ try {
745
+ const scopedQuery = this.resolveScopedFuzzyQuery(query)
746
+ const fdBaseDir = scopedQuery?.baseDir ?? this.basePath
747
+ const fdQuery = scopedQuery?.query ?? query
748
+ const entries = await walkDirectoryWithFd(
749
+ fdBaseDir,
750
+ this.fdPath,
751
+ fdQuery,
752
+ 100,
753
+ options.signal,
754
+ )
755
+ if (options.signal.aborted) {
756
+ return []
757
+ }
758
+
759
+ const scoredEntries = entries
760
+ .map(entry => ({
761
+ ...entry,
762
+ score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
763
+ }))
764
+ .filter(entry => entry.score > 0)
765
+
766
+ scoredEntries.sort((a, b) => b.score - a.score)
767
+ const topEntries = scoredEntries.slice(0, 20)
768
+
769
+ const suggestions: AutocompleteItem[] = []
770
+ for (const { path: entryPath, isDirectory } of topEntries) {
771
+ const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath
772
+ const displayPath = scopedQuery
773
+ ? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
774
+ : pathWithoutSlash
775
+ const entryName = basename(pathWithoutSlash)
776
+ const completionPath = isDirectory ? `${displayPath}/` : displayPath
777
+ const value = buildCompletionValue(completionPath, {
778
+ isDirectory,
779
+ isAtPrefix: true,
780
+ isQuotedPrefix: options.isQuotedPrefix,
781
+ })
782
+
783
+ suggestions.push({
784
+ value,
785
+ label: entryName + (isDirectory ? '/' : ''),
786
+ description: displayPath,
787
+ })
788
+ }
789
+
790
+ return suggestions
791
+ } catch {
792
+ return []
793
+ }
794
+ }
795
+
796
+ // Check if we should trigger file completion (called on Tab key)
797
+ shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
798
+ const currentLine = lines[cursorLine] || ''
799
+ const textBeforeCursor = currentLine.slice(0, cursorCol)
800
+
801
+ // Don't trigger if we're typing a slash command at the start of the line
802
+ if (textBeforeCursor.trim().startsWith('/') && !textBeforeCursor.trim().includes(' ')) {
803
+ return false
804
+ }
805
+
806
+ return true
807
+ }
808
+ }