Skip to content
Draft
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)) {
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 }));
}

// 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('');
}
},
[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