1
1
import type { MultilineTextEditorHandle } from "./multiline-editor" ;
2
2
import type { ReviewDecision } from "../../utils/agent/review.js" ;
3
+ import type { FileSystemSuggestion } from "../../utils/file-system-suggestions.js" ;
3
4
import type { HistoryEntry } from "../../utils/storage/command-history.js" ;
4
5
import type {
5
6
ResponseInputItem ,
@@ -11,6 +12,7 @@ import { TerminalChatCommandReview } from "./terminal-chat-command-review.js";
11
12
import TextCompletions from "./terminal-chat-completions.js" ;
12
13
import { loadConfig } from "../../utils/config.js" ;
13
14
import { getFileSystemSuggestions } from "../../utils/file-system-suggestions.js" ;
15
+ import { expandFileTags } from "../../utils/file-tag-utils" ;
14
16
import { createInputItem } from "../../utils/input-utils.js" ;
15
17
import { log } from "../../utils/logger/log.js" ;
16
18
import { setSessionId } from "../../utils/session.js" ;
@@ -92,16 +94,120 @@ export default function TerminalChatInput({
92
94
const [ historyIndex , setHistoryIndex ] = useState < number | null > ( null ) ;
93
95
const [ draftInput , setDraftInput ] = useState < string > ( "" ) ;
94
96
const [ skipNextSubmit , setSkipNextSubmit ] = useState < boolean > ( false ) ;
95
- const [ fsSuggestions , setFsSuggestions ] = useState < Array < string > > ( [ ] ) ;
97
+ const [ fsSuggestions , setFsSuggestions ] = useState <
98
+ Array < FileSystemSuggestion >
99
+ > ( [ ] ) ;
96
100
const [ selectedCompletion , setSelectedCompletion ] = useState < number > ( - 1 ) ;
97
101
// Multiline text editor key to force remount after submission
98
- const [ editorKey , setEditorKey ] = useState ( 0 ) ;
102
+ const [ editorState , setEditorState ] = useState < {
103
+ key : number ;
104
+ initialCursorOffset ?: number ;
105
+ } > ( { key : 0 } ) ;
99
106
// Imperative handle from the multiline editor so we can query caret position
100
107
const editorRef = useRef < MultilineTextEditorHandle | null > ( null ) ;
101
108
// Track the caret row across keystrokes
102
109
const prevCursorRow = useRef < number | null > ( null ) ;
103
110
const prevCursorWasAtLastRow = useRef < boolean > ( false ) ;
104
111
112
+ // --- Helper for updating input, remounting editor, and moving cursor to end ---
113
+ const applyFsSuggestion = useCallback ( ( newInputText : string ) => {
114
+ setInput ( newInputText ) ;
115
+ setEditorState ( ( s ) => ( {
116
+ key : s . key + 1 ,
117
+ initialCursorOffset : newInputText . length ,
118
+ } ) ) ;
119
+ } , [ ] ) ;
120
+
121
+ // --- Helper for updating file system suggestions ---
122
+ function updateFsSuggestions (
123
+ txt : string ,
124
+ alwaysUpdateSelection : boolean = false ,
125
+ ) {
126
+ // Clear file system completions if a space is typed
127
+ if ( txt . endsWith ( " " ) ) {
128
+ setFsSuggestions ( [ ] ) ;
129
+ setSelectedCompletion ( - 1 ) ;
130
+ } else {
131
+ // Determine the current token (last whitespace-separated word)
132
+ const words = txt . trim ( ) . split ( / \s + / ) ;
133
+ const lastWord = words [ words . length - 1 ] ?? "" ;
134
+
135
+ const shouldUpdateSelection =
136
+ lastWord . startsWith ( "@" ) || alwaysUpdateSelection ;
137
+
138
+ // Strip optional leading '@' for the path prefix
139
+ let pathPrefix : string ;
140
+ if ( lastWord . startsWith ( "@" ) ) {
141
+ pathPrefix = lastWord . slice ( 1 ) ;
142
+ // If only '@' is typed, list everything in the current directory
143
+ pathPrefix = pathPrefix . length === 0 ? "./" : pathPrefix ;
144
+ } else {
145
+ pathPrefix = lastWord ;
146
+ }
147
+
148
+ if ( shouldUpdateSelection ) {
149
+ const completions = getFileSystemSuggestions ( pathPrefix ) ;
150
+ setFsSuggestions ( completions ) ;
151
+ if ( completions . length > 0 ) {
152
+ setSelectedCompletion ( ( prev ) =>
153
+ prev < 0 || prev >= completions . length ? 0 : prev ,
154
+ ) ;
155
+ } else {
156
+ setSelectedCompletion ( - 1 ) ;
157
+ }
158
+ } else if ( fsSuggestions . length > 0 ) {
159
+ // Token cleared → clear menu
160
+ setFsSuggestions ( [ ] ) ;
161
+ setSelectedCompletion ( - 1 ) ;
162
+ }
163
+ }
164
+ }
165
+
166
+ /**
167
+ * Result of replacing text with a file system suggestion
168
+ */
169
+ interface ReplacementResult {
170
+ /** The new text with the suggestion applied */
171
+ text : string ;
172
+ /** The selected suggestion if a replacement was made */
173
+ suggestion : FileSystemSuggestion | null ;
174
+ /** Whether a replacement was actually made */
175
+ wasReplaced : boolean ;
176
+ }
177
+
178
+ // --- Helper for replacing input with file system suggestion ---
179
+ function getFileSystemSuggestion (
180
+ txt : string ,
181
+ requireAtPrefix : boolean = false ,
182
+ ) : ReplacementResult {
183
+ if ( fsSuggestions . length === 0 || selectedCompletion < 0 ) {
184
+ return { text : txt , suggestion : null , wasReplaced : false } ;
185
+ }
186
+
187
+ const words = txt . trim ( ) . split ( / \s + / ) ;
188
+ const lastWord = words [ words . length - 1 ] ?? "" ;
189
+
190
+ // Check if @ prefix is required and the last word doesn't have it
191
+ if ( requireAtPrefix && ! lastWord . startsWith ( "@" ) ) {
192
+ return { text : txt , suggestion : null , wasReplaced : false } ;
193
+ }
194
+
195
+ const selected = fsSuggestions [ selectedCompletion ] ;
196
+ if ( ! selected ) {
197
+ return { text : txt , suggestion : null , wasReplaced : false } ;
198
+ }
199
+
200
+ const replacement = lastWord . startsWith ( "@" )
201
+ ? `@${ selected . path } `
202
+ : selected . path ;
203
+ words [ words . length - 1 ] = replacement ;
204
+ return {
205
+ text : words . join ( " " ) ,
206
+ suggestion : selected ,
207
+ wasReplaced : true ,
208
+ } ;
209
+ }
210
+
105
211
// Load command history on component mount
106
212
useEffect ( ( ) => {
107
213
async function loadHistory ( ) {
@@ -223,21 +329,12 @@ export default function TerminalChatInput({
223
329
}
224
330
225
331
if ( _key . tab && selectedCompletion >= 0 ) {
226
- const words = input . trim ( ) . split ( / \s + / ) ;
227
- const selected = fsSuggestions [ selectedCompletion ] ;
228
-
229
- if ( words . length > 0 && selected ) {
230
- words [ words . length - 1 ] = selected ;
231
- const newText = words . join ( " " ) ;
232
- setInput ( newText ) ;
233
- // Force remount of the editor with the new text
234
- setEditorKey ( ( k ) => k + 1 ) ;
235
-
236
- // We need to move the cursor to the end after editor remounts
237
- setTimeout ( ( ) => {
238
- editorRef . current ?. moveCursorToEnd ?.( ) ;
239
- } , 0 ) ;
332
+ const { text : newText , wasReplaced } =
333
+ getFileSystemSuggestion ( input ) ;
240
334
335
+ // Only proceed if the text was actually changed
336
+ if ( wasReplaced ) {
337
+ applyFsSuggestion ( newText ) ;
241
338
setFsSuggestions ( [ ] ) ;
242
339
setSelectedCompletion ( - 1 ) ;
243
340
}
@@ -277,7 +374,7 @@ export default function TerminalChatInput({
277
374
278
375
setInput ( history [ newIndex ] ?. command ?? "" ) ;
279
376
// Re-mount the editor so it picks up the new initialText
280
- setEditorKey ( ( k ) => k + 1 ) ;
377
+ setEditorState ( ( s ) => ( { key : s . key + 1 } ) ) ;
281
378
return ; // handled
282
379
}
283
380
@@ -296,28 +393,23 @@ export default function TerminalChatInput({
296
393
if ( newIndex >= history . length ) {
297
394
setHistoryIndex ( null ) ;
298
395
setInput ( draftInput ) ;
299
- setEditorKey ( ( k ) => k + 1 ) ;
396
+ setEditorState ( ( s ) => ( { key : s . key + 1 } ) ) ;
300
397
} else {
301
398
setHistoryIndex ( newIndex ) ;
302
399
setInput ( history [ newIndex ] ?. command ?? "" ) ;
303
- setEditorKey ( ( k ) => k + 1 ) ;
400
+ setEditorState ( ( s ) => ( { key : s . key + 1 } ) ) ;
304
401
}
305
402
return ; // handled
306
403
}
307
404
// Otherwise let it propagate
308
405
}
309
406
310
- if ( _key . tab ) {
311
- const words = input . split ( / \s + / ) ;
312
- const mostRecentWord = words [ words . length - 1 ] ;
313
- if ( mostRecentWord === undefined || mostRecentWord === "" ) {
314
- return ;
315
- }
316
- const completions = getFileSystemSuggestions ( mostRecentWord ) ;
317
- setFsSuggestions ( completions ) ;
318
- if ( completions . length > 0 ) {
319
- setSelectedCompletion ( 0 ) ;
320
- }
407
+ // Defer filesystem suggestion logic to onSubmit if enter key is pressed
408
+ if ( ! _key . return ) {
409
+ // Pressing tab should trigger the file system suggestions
410
+ const shouldUpdateSelection = _key . tab ;
411
+ const targetInput = _key . delete ? input . slice ( 0 , - 1 ) : input + _input ;
412
+ updateFsSuggestions ( targetInput , shouldUpdateSelection ) ;
321
413
}
322
414
}
323
415
@@ -599,7 +691,10 @@ export default function TerminalChatInput({
599
691
) ;
600
692
text = text . trim ( ) ;
601
693
602
- const inputItem = await createInputItem ( text , images ) ;
694
+ // Expand @file tokens into XML blocks for the model
695
+ const expandedText = await expandFileTags ( text ) ;
696
+
697
+ const inputItem = await createInputItem ( expandedText , images ) ;
603
698
submitInput ( [ inputItem ] ) ;
604
699
605
700
// Get config for history persistence.
@@ -673,28 +768,30 @@ export default function TerminalChatInput({
673
768
setHistoryIndex ( null ) ;
674
769
}
675
770
setInput ( txt ) ;
676
-
677
- // Clear tab completions if a space is typed
678
- if ( txt . endsWith ( " " ) ) {
679
- setFsSuggestions ( [ ] ) ;
680
- setSelectedCompletion ( - 1 ) ;
681
- } else if ( fsSuggestions . length > 0 ) {
682
- // Update file suggestions as user types
683
- const words = txt . trim ( ) . split ( / \s + / ) ;
684
- const mostRecentWord =
685
- words . length > 0 ? words [ words . length - 1 ] : "" ;
686
- if ( mostRecentWord !== undefined ) {
687
- setFsSuggestions ( getFileSystemSuggestions ( mostRecentWord ) ) ;
688
- }
689
- }
690
771
} }
691
- key = { editorKey }
772
+ key = { editorState . key }
773
+ initialCursorOffset = { editorState . initialCursorOffset }
692
774
initialText = { input }
693
775
height = { 6 }
694
776
focus = { active }
695
777
onSubmit = { ( txt ) => {
696
- onSubmit ( txt ) ;
697
- setEditorKey ( ( k ) => k + 1 ) ;
778
+ // If final token is an @path , replace with filesystem suggestion if available
779
+ const {
780
+ text : replacedText ,
781
+ suggestion,
782
+ wasReplaced,
783
+ } = getFileSystemSuggestion ( txt , true ) ;
784
+
785
+ // If we replaced @path token with a directory, don't submit
786
+ if ( wasReplaced && suggestion ?. isDirectory ) {
787
+ applyFsSuggestion ( replacedText ) ;
788
+ // Update suggestions for the new directory
789
+ updateFsSuggestions ( replacedText , true ) ;
790
+ return ;
791
+ }
792
+
793
+ onSubmit ( replacedText ) ;
794
+ setEditorState ( ( s ) => ( { key : s . key + 1 } ) ) ;
698
795
setInput ( "" ) ;
699
796
setHistoryIndex ( null ) ;
700
797
setDraftInput ( "" ) ;
@@ -741,7 +838,7 @@ export default function TerminalChatInput({
741
838
</ Text >
742
839
) : fsSuggestions . length > 0 ? (
743
840
< TextCompletions
744
- completions = { fsSuggestions }
841
+ completions = { fsSuggestions . map ( ( suggestion ) => suggestion . path ) }
745
842
selectedCompletion = { selectedCompletion }
746
843
displayLimit = { 5 }
747
844
/>
0 commit comments