Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -233,3 +233,11 @@
}
}
}

/* Ghost text for chat autocomplete */
.chat-ghost-text {
color: var(--muted-foreground);
opacity: 0.6;
pointer-events: none;
user-select: none;
}
64 changes: 50 additions & 14 deletions apps/web/src/components/cloud-agent-next/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ModelCombobox, type ModelOption } from '@/components/shared/ModelCombob
import { VariantCombobox } from '@/components/shared/VariantCombobox';
import { MobileToolbarPopover } from './MobileToolbarPopover';
import type { AgentMode } from './types';
import { useChatGhostText } from './hooks/useChatGhostText';

type ChatInputProps = {
onSend: (message: string) => void;
Expand Down Expand Up @@ -44,6 +45,7 @@ type ChatInputProps = {
showToolbar?: boolean;
/** Pre-populate the textarea (e.g. to restore text after a failed send) */
initialValue?: string;
enableChatAutocomplete?: boolean;
};

export function ChatInput({
Expand All @@ -64,12 +66,22 @@ export function ChatInput({
availableVariants = [],
showToolbar = false,
initialValue,
enableChatAutocomplete = true,
}: ChatInputProps) {
const [value, setValue] = useState('');
const [showAutocomplete, setShowAutocomplete] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);

const {
ghostText,
handleKeyDown: handleGhostTextKeyDown,
handleInputChange: handleGhostTextInputChange,
} = useChatGhostText({
textAreaRef: textareaRef,
enableChatAutocomplete: enableChatAutocomplete && !disabled && !isStreaming,
});

// Restore text into the textarea when initialValue changes (e.g. after a failed send).
// Treats undefined as "no opinion" (skip), but empty string actively clears the field.
useEffect(() => {
Expand Down Expand Up @@ -121,6 +133,7 @@ export function ChatInput({

onSend(trimmed);
setValue('');
handleGhostTextInputChange('');
setShowAutocomplete(false);

if (textareaRef.current) {
Expand All @@ -143,12 +156,14 @@ export function ChatInput({
// Send immediately
onSend(expansion);
setValue('');
handleGhostTextInputChange('');
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
}
} else {
// Just fill the input for editing
setValue(expansion);
handleGhostTextInputChange(expansion);
// Force height recalculation for expanded text
if (textareaRef.current) {
textareaRef.current.style.height = 'auto';
Expand All @@ -164,6 +179,10 @@ export function ChatInput({
// Ignore keyboard events during IME composition (Chinese, Japanese, Korean input)
if (e.nativeEvent.isComposing || e.nativeEvent.keyCode === 229) return;

if (handleGhostTextKeyDown(e)) {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Ghost-text shortcuts now take precedence over slash-command shortcuts

handleGhostTextKeyDown runs before the existing slash-command autocomplete logic. When the input starts with / and a ghost suggestion is present, pressing Tab will accept ghost text instead of selecting the highlighted slash command, which regresses the current slash-command keyboard flow. Slash-command handling should keep precedence while that popover is open.

return;
}

if (showAutocomplete && filteredCommands.length > 0) {
switch (e.key) {
case 'ArrowDown':
Expand Down Expand Up @@ -224,20 +243,37 @@ export function ChatInput({
{/* Textarea with slash command autocomplete */}
<Popover open={showAutocomplete} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>
<textarea
ref={textareaRef}
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="max-h-[200px] w-full resize-none overflow-y-auto border-0 bg-transparent p-4 pb-2 text-sm focus:ring-0 focus:outline-none"
rows={1}
role="combobox"
aria-expanded={showAutocomplete}
aria-autocomplete="list"
aria-controls="slash-command-list"
/>
<div className="relative w-full">
<textarea
ref={textareaRef}
value={value}
onChange={e => {
setValue(e.target.value);
handleGhostTextInputChange(e.target.value);
}}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={disabled}
className="max-h-[200px] w-full resize-none overflow-y-auto border-0 bg-transparent p-4 pb-2 text-sm focus:ring-0 focus:outline-none"
rows={1}
role="combobox"
aria-expanded={showAutocomplete}
aria-autocomplete="list"
aria-controls="slash-command-list"
/>
{/* Ghost text overlay */}
{ghostText && (
<div
className="pointer-events-none absolute left-0 top-0 flex h-full w-full select-none items-start overflow-hidden p-4 pb-2"
aria-hidden="true"
>
<span className="invisible whitespace-pre-wrap break-words text-sm">{value}</span>
<span className="chat-ghost-text whitespace-pre-wrap break-words text-sm">
{ghostText}
</span>
</div>
)}
</div>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[min(300px,calc(100vw-2rem))] p-0"
Expand Down
242 changes: 242 additions & 0 deletions apps/web/src/components/cloud-agent-next/hooks/useChatGhostText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,242 @@
import {
useState,
useRef,
useCallback,
useEffect,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react';
import { useRawTRPCClient } from '@/lib/trpc/utils';

type UseChatGhostTextProps = {
textAreaRef: React.RefObject<HTMLTextAreaElement | null>;
enableChatAutocomplete: boolean;
};

// Generate a unique ID for each request to avoid race conditions
function generateRequestId(): string {
return Math.random().toString(36).substring(2, 15);
}

// Insert text at cursor position in a textarea
function insertTextAtCursor(textarea: HTMLTextAreaElement, text: string): void {
const { selectionStart, value } = textarea;
const newValue = value.slice(0, selectionStart) + text + value.slice(selectionStart);
textarea.value = newValue;
// Move cursor to end of inserted text
const newCursorPos = selectionStart + text.length;
textarea.setSelectionRange(newCursorPos, newCursorPos);
// Trigger input event so React sees the change
textarea.dispatchEvent(new Event('input', { bubbles: true }));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Accepting a suggestion does not reliably sync the controlled textarea state

This helper mutates textarea.value directly and then relies on a synthetic input event to drive onChange, but the textarea is controlled by React via value={value}. React's value tracker usually treats programmatic element.value = ... writes as already observed, so onChange may never fire here. When that happens the accepted text can disappear on the next render or leave derived UI like the send-button disabled state out of sync.

}

// Extract the next word from ghost text (for ArrowRight partial acceptance)
function extractNextWord(text: string): { word: string; remainder: string } {
const match = text.match(/^(\s*\S+)/);
if (!match) {
return { word: text, remainder: '' };
}
const word = match[1];
const remainder = text.slice(word.length);
return { word, remainder };
}

let debugEnabled = false;
if (typeof window !== 'undefined') {
debugEnabled = window.localStorage?.getItem('debug:cloud-agent:autocomplete') === 'true';
}

function debugCloudAgentAutocomplete(label: string, data?: unknown): void {
if (debugEnabled) {
console.log(`[useChatGhostText:${label}]`, data);
}
}

/**
* Hook that manages ghost text autocomplete for the cloud agent chat input.
* Provides debounced FIM completion suggestions and keyboard handlers for accepting them.
*/
export function useChatGhostText({ textAreaRef, enableChatAutocomplete }: UseChatGhostTextProps) {
const [ghostText, setGhostText] = useState('');
const completionDebounceRef = useRef<NodeJS.Timeout | null>(null);
const skipNextCompletionRef = useRef(false);
const completionRequestIdRef = useRef<string>('');
const trpcClient = useRawTRPCClient();

const requestCompletion = useCallback(
async ({ prefix, requestId }: { prefix: string; requestId: string }) => {
console.log('[useChatGhostText] requestCompletion called', {
prefixLength: prefix.length,
requestId,
currentRequestId: completionRequestIdRef.current,
});
debugCloudAgentAutocomplete('request', {
prefixLength: prefix.length,
requestId,
});

// Only process if this is still the latest request
if (requestId !== completionRequestIdRef.current) {
console.log('[useChatGhostText] stale request, ignoring', {
requestId,
currentRequestId: completionRequestIdRef.current,
});
debugCloudAgentAutocomplete('stale', { requestId });
return;
}

try {
const result = await trpcClient.cloudAgent.getFimAutocomplete.mutate({
prefix,
suffix: '',
requestId,
});

console.log('[useChatGhostText] got result', {
requestId,
currentRequestId: completionRequestIdRef.current,
suggestionLength: result.suggestion.length,
suggestionPreview: result.suggestion.slice(0, 50),
});
debugCloudAgentAutocomplete('result', {
requestId,
suggestionLength: result.suggestion.length,
});

// Only update ghost text if this is still the latest request
if (requestId === completionRequestIdRef.current) {
setGhostText(result.suggestion);
} else {
console.log('[useChatGhostText] result is stale, not updating ghost text', {
requestId,
currentRequestId: completionRequestIdRef.current,
});
debugCloudAgentAutocomplete('result-stale', { requestId });
}
} catch (error) {
debugCloudAgentAutocomplete('error', error);
// Silently ignore errors - just don't show ghost text
setGhostText('');
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Stale failed requests can erase a newer completion

The success path only updates ghostText when requestId === completionRequestIdRef.current, but this catch block always calls setGhostText(''). If request A fails after request B has already started or populated a suggestion, A will clear B's result even though it is stale. The error path needs the same request-id guard as the success path.

}
},
[trpcClient]
);

const clearGhostText = useCallback(() => {
setGhostText('');
}, []);

const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLTextAreaElement>): boolean => {
const textArea = textAreaRef.current;
if (!textArea) {
return false;
}

const hasSelection = textArea.selectionStart !== textArea.selectionEnd;
const isCursorAtEnd = textArea.selectionStart === textArea.value.length;
const canAcceptCompletion = ghostText && !hasSelection && isCursorAtEnd;

// Tab: Accept full ghost text
if (event.key === 'Tab' && !event.shiftKey && canAcceptCompletion) {
debugCloudAgentAutocomplete('accept', { via: 'Tab', ghostText });
event.preventDefault();
skipNextCompletionRef.current = true;
insertTextAtCursor(textArea, ghostText);
setGhostText('');
return true;
}

// ArrowRight: Accept next word only
if (
event.key === 'ArrowRight' &&
!event.shiftKey &&
!event.ctrlKey &&
!event.metaKey &&
canAcceptCompletion
) {
debugCloudAgentAutocomplete('accept', { via: 'ArrowRight', ghostText });
event.preventDefault();
skipNextCompletionRef.current = true;
const { word, remainder } = extractNextWord(ghostText);
insertTextAtCursor(textArea, word);
setGhostText(remainder);
return true;
}

// Escape: Clear ghost text
if (event.key === 'Escape' && ghostText) {
debugCloudAgentAutocomplete('dismiss', { via: 'Escape' });
event.preventDefault();
clearGhostText();
// Generate new request ID to cancel any pending requests
completionRequestIdRef.current = generateRequestId();
return true;
}

return false;
},
[ghostText, textAreaRef, clearGhostText]
);

const handleInputChange = useCallback(
(newValue: string) => {
if (!enableChatAutocomplete) {
setGhostText('');
return;
}

if (skipNextCompletionRef.current) {
skipNextCompletionRef.current = false;
setGhostText('');
return;
}

// Clear ghost text immediately when input changes
setGhostText('');

// Cancel any pending debounced request
if (completionDebounceRef.current) {
clearTimeout(completionDebounceRef.current);
}

// Don't request completion for empty/short input
if (newValue.trim().length < 5) {
completionRequestIdRef.current = '';
return;
}

// Debounce the completion request
const requestId = generateRequestId();
completionRequestIdRef.current = requestId;

completionDebounceRef.current = setTimeout(() => {
void requestCompletion({ prefix: newValue, requestId });
}, 400);
},
[enableChatAutocomplete, requestCompletion]
);

// Clean up debounce timer on unmount
useEffect(() => {
return () => {
if (completionDebounceRef.current) {
clearTimeout(completionDebounceRef.current);
}
};
}, []);

// Clear ghost text when autocomplete is disabled
useEffect(() => {
if (!enableChatAutocomplete) {
setGhostText('');
completionRequestIdRef.current = '';
}
}, [enableChatAutocomplete]);

return {
ghostText,
handleKeyDown,
handleInputChange,
clearGhostText,
};
}
Loading
Loading