Skip to content

Commit c428e2e

Browse files
committed
Major improvements to our AgentUI.ts for @ mention files, 5x faster, les than 16ms now;
typing now like @src + Tab now auto completes the first classified file, Fixes Applied src/ui/ink/AgentUI.tsx • Synchronous mention detection in handleInput: when the buffer changes, mentions are detected immediately and refs are updated so Tab works without waiting for e 16ms React state flush • Stale-state guard in the mention useEffect: skips processing when input/cursorOffset lag behind the buffer • Buffer-accurate cursor offset in Tab handling: uses getTextBufferCursorOffset(buffer) instead of the stale cursorOffsetRef.current src/ui/mentionPreview.ts • Synchronous suggestion refresh for Tab and arrow keys: updateSuggestions() is called immediately inside handleKeypress before handling navigation/acceptance, e uring fresh data src/ui/useBufferedInput.ts • Added clear documentation explaining why stdin cannot be safely connected alongside Ink • Removed the broken 'sequence' listener in favor of correct 'data'/'paste' listeners on StdinBuffer (ready for future safe stdin interception)
1 parent 3971f96 commit c428e2e

7 files changed

Lines changed: 665 additions & 9 deletions

File tree

src/ui/ink/AgentUI.tsx

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,8 @@ export function AgentUI({
238238
onToggleLiveCommandExpandedRef.current = onToggleLiveCommandExpanded;
239239
const onInstructionRef = useRef(onInstruction);
240240
onInstructionRef.current = onInstruction;
241+
const filesProviderRef = useRef(filesProvider);
242+
filesProviderRef.current = filesProvider;
241243

242244
// Throttled sync from buffer to React state to batch rapid keystrokes
243245
// and reduce re-render frequency during fast typing (16ms = ~60fps).
@@ -385,6 +387,14 @@ export function AgentUI({
385387
return;
386388
}
387389

390+
// Guard against stale React state: if the buffer already has newer text
391+
// (because the 16ms throttle hasn't flushed yet), skip processing.
392+
// The synchronous handler in handleInput already updated the refs.
393+
const buffer = textBufferRef.current;
394+
if (input !== buffer.getText() || cursorOffset !== getTextBufferCursorOffset(buffer)) {
395+
return;
396+
}
397+
388398
const mention = matchFileMention(input, cursorOffset);
389399
if (!mention) {
390400
setFileMentionVisible(false);
@@ -488,7 +498,7 @@ export function AgentUI({
488498
const buffer = textBufferRef.current;
489499
const currentText = buffer.getText();
490500
const beforeMention = currentText.slice(0, fileMentionStartIndexRef.current);
491-
const afterCursor = currentText.slice(cursorOffsetRef.current);
501+
const afterCursor = currentText.slice(getTextBufferCursorOffset(buffer));
492502
const replacement = `@${suggestion.path} `;
493503
const newText = beforeMention + replacement + afterCursor;
494504

@@ -527,6 +537,42 @@ export function AgentUI({
527537

528538
if (result === 'handled') {
529539
syncInputFromBuffer();
540+
541+
// Immediate mention detection so Tab works without waiting for the 16ms
542+
// React state throttle. This eliminates the intermittent failure where
543+
// rapid typing followed by Tab is ignored because mention state hasn't
544+
// been flushed to React yet.
545+
const currentText = buffer.getText();
546+
const currentOffset = getTextBufferCursorOffset(buffer);
547+
const provider = filesProviderRef.current;
548+
if (provider) {
549+
const mention = matchFileMention(currentText, currentOffset);
550+
if (mention) {
551+
const files = provider();
552+
const matchingFiles = buildFileMentionSuggestions(files, mention.seed, 5);
553+
if (matchingFiles.length > 0) {
554+
fileMentionStartIndexRef.current = mention.startIndex;
555+
fileMentionSuggestionsRef.current = parseFileSuggestions(matchingFiles);
556+
fileMentionVisibleRef.current = true;
557+
setFileMentionSuggestions(fileMentionSuggestionsRef.current);
558+
setFileMentionVisible(true);
559+
setFileMentionActiveIndex(prev => Math.min(prev, matchingFiles.length - 1));
560+
} else {
561+
fileMentionVisibleRef.current = false;
562+
fileMentionSuggestionsRef.current = [];
563+
fileMentionStartIndexRef.current = null;
564+
setFileMentionVisible(false);
565+
setFileMentionSuggestions([]);
566+
}
567+
} else if (fileMentionVisibleRef.current) {
568+
fileMentionVisibleRef.current = false;
569+
fileMentionSuggestionsRef.current = [];
570+
fileMentionStartIndexRef.current = null;
571+
setFileMentionVisible(false);
572+
setFileMentionSuggestions([]);
573+
}
574+
}
575+
530576
return;
531577
}
532578
}, [syncBufferViewport, syncInputFromBuffer, exit]);

src/ui/mentionPreview.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,15 @@ export class MentionPreview {
168168
if (this.disposed || this.suspended) {
169169
return;
170170
}
171+
172+
// For navigation/acceptance keys, refresh suggestions synchronously so
173+
// they reflect the current rl.line. Without this, a Tab pressed rapidly
174+
// after a character can use stale suggestion data because the deferred
175+
// setImmediate(updateSuggestions) hasn't fired yet.
176+
if (this.isTabKey(_str, key) || key?.name === 'down' || key?.name === 'up') {
177+
this.updateSuggestions();
178+
}
179+
171180
const beforeCursor = this.rl.line.slice(0, this.rl.cursor);
172181

173182
// Tab and arrow keys must be handled synchronously (before readline processes them)

src/ui/useBufferedInput.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -210,19 +210,36 @@ export function useBufferedInput(options: UseBufferedInputOptions): void {
210210
if (!stdin || !isActive) {
211211
return;
212212
}
213-
213+
214214
const buffer = new StdinBuffer({ timeout: flushTimeout });
215215
bufferRef.current = buffer;
216-
217-
// Handle sequence events from the buffer
218-
const handleSequence = (event: SequenceEvent) => {
219-
const info = sequenceToInkInput(event);
216+
217+
// IMPORTANT: We intentionally do NOT attach stdin.on('data') here.
218+
// Ink's App component uses stdin 'readable' events to read input.
219+
// Adding a 'data' listener would switch the stream to flowing mode and
220+
// prevent Ink from receiving keystrokes. A future refactor should find
221+
// a safe way to intercept stdin data (e.g., wrapping stdin.read()) so
222+
// that bracketed-paste and Kitty-protocol events can be detected.
223+
224+
// Handle sequence events from the buffer (currently only triggered
225+
// by direct buffer.process() calls from external code).
226+
const handleData = (data: string) => {
227+
const type: SequenceEvent['type'] = data.startsWith('\x1b') ? 'csi' : 'printable';
228+
const info = sequenceToInkInput({ type, data });
220229
onInputRef.current(info.input, info.key, info);
221230
};
222-
223-
buffer.on('sequence', handleSequence);
224-
231+
232+
const handlePaste = (data: string) => {
233+
const info = sequenceToInkInput({ type: 'paste', data });
234+
onInputRef.current(info.input, info.key, info);
235+
};
236+
237+
buffer.on('data', handleData);
238+
buffer.on('paste', handlePaste);
239+
225240
return () => {
241+
buffer.off('data', handleData);
242+
buffer.off('paste', handlePaste);
226243
buffer.destroy();
227244
bufferRef.current = null;
228245
};

0 commit comments

Comments
 (0)