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 src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -182,3 +182,11 @@
animation: pulse-glow 0.6s ease-out;
}
}

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

type ChatInputProps = {
onSend: (message: string) => void;
Expand All @@ -35,6 +36,7 @@
onModelChange?: (model: string) => void;
/** Whether to show the toolbar (hide when no active session) */
showToolbar?: boolean;
enableChatAutocomplete?: boolean;
};

export function ChatInput({
Expand All @@ -51,12 +53,22 @@
onModeChange,
onModelChange,
showToolbar = false,
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,

Check failure on line 68 in src/components/cloud-agent/ChatInput.tsx

View workflow job for this annotation

GitHub Actions / test

Type 'RefObject<HTMLTextAreaElement | null>' is not assignable to type 'RefObject<HTMLTextAreaElement>'.
enableChatAutocomplete: enableChatAutocomplete && !disabled && !isStreaming,
});

// Filter commands based on current input
const filteredCommands = useMemo(() => {
if (!slashCommands || slashCommands.length === 0) return [];
Expand Down Expand Up @@ -99,6 +111,7 @@

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

if (textareaRef.current) {
Expand All @@ -121,12 +134,14 @@
// 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 @@ -139,6 +154,10 @@
};

const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (handleGhostTextKeyDown(e)) {
return;
}

if (showAutocomplete && filteredCommands.length > 0) {
switch (e.key) {
case 'ArrowDown':
Expand Down Expand Up @@ -213,20 +232,39 @@
<div className="flex w-full max-w-full flex-col items-stretch gap-2 md:flex-row md:flex-wrap md:items-start md:gap-3">
<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] min-h-[56px] w-full min-w-0 resize-y md:min-h-[60px] md:flex-1"
rows={1}
role="combobox"
aria-expanded={showAutocomplete}
aria-autocomplete="list"
aria-controls="slash-command-list"
/>
<div className="relative w-full min-w-0 md:flex-1">
<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] min-h-[56px] w-full resize-y md:min-h-[60px]"
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 px-3 py-2"
aria-hidden="true"
>
<span className="invisible whitespace-pre-wrap break-words">
{value}
</span>
<span className="chat-ghost-text whitespace-pre-wrap break-words">
{ghostText}
</span>
</div>
)}
</div>
</PopoverAnchor>
<PopoverContent
className="w-[var(--radix-popover-trigger-width)] min-w-[300px] p-0"
Expand Down
259 changes: 259 additions & 0 deletions src/components/cloud-agent/hooks/useChatGhostText.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { useState, useRef, useCallback, useEffect, type KeyboardEvent as ReactKeyboardEvent } from 'react';
import { trpc } from '@/lib/trpc.client';

Check failure on line 2 in src/components/cloud-agent/hooks/useChatGhostText.ts

View workflow job for this annotation

GitHub Actions / test

Cannot find module '@/lib/trpc.client' or its corresponding type declarations.

type UseChatGhostTextProps = {
textAreaRef: React.RefObject<HTMLTextAreaElement>;
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 = trpc.useUtils();

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
) {
const { word, remainder } = extractNextWord(ghostText);
debugCloudAgentAutocomplete('accept', {
via: 'ArrowRight',
word,
remainderPreview: remainder.slice(0, 120),
});
event.preventDefault();
skipNextCompletionRef.current = true;
insertTextAtCursor(textArea, word);
setGhostText(remainder);
return true;
}

// Escape: Clear ghost text
if (event.key === 'Escape' && ghostText) {
debugCloudAgentAutocomplete('clear', { via: 'Escape' });
setGhostText('');
}
return false;
},
[ghostText, textAreaRef]
);

const handleInputChange = useCallback(
(newValue: string) => {
console.log('[useChatGhostText] handleInputChange called', {
enableChatAutocomplete,
newValueLength: newValue.length,
});
debugCloudAgentAutocomplete('input-change', {
enableChatAutocomplete,
newValueLength: newValue.length,
startsWithSlash: newValue.startsWith('/'),
includesAt: newValue.includes('@'),
skipNext: skipNextCompletionRef.current,
});

// Clear any existing ghost text when typing
setGhostText('');

// Clear any pending completion request
if (completionDebounceRef.current) {
clearTimeout(completionDebounceRef.current);
}

// Skip completion request if we just accepted a suggestion (Tab) or undid
if (skipNextCompletionRef.current) {
console.log('[useChatGhostText] skipping - skipNextCompletionRef is true');
skipNextCompletionRef.current = false;
// Don't request a new completion - wait for user to type more
} else if (
enableChatAutocomplete &&
newValue.length >= 5 &&
!newValue.startsWith('/') &&
!newValue.includes('@')
) {
// Request new completion after debounce (only if feature is enabled)
const requestId = generateRequestId();
completionRequestIdRef.current = requestId;

console.log('[useChatGhostText] scheduling completion request', {
requestId,
debounceMs: 300,
textLength: newValue.length,
});
debugCloudAgentAutocomplete('schedule', {
requestId,
debounceMs: 300,
});

completionDebounceRef.current = setTimeout(() => {
console.log('[useChatGhostText] debounce fired, calling requestCompletion', {
requestId,
});
void requestCompletion({
prefix: newValue,
requestId,
});
}, 300); // 300ms debounce
} else {
console.log('[useChatGhostText] not scheduling', {
enableChatAutocomplete,
length: newValue.length,
startsWithSlash: newValue.startsWith('/'),
includesAt: newValue.includes('@'),
});
debugCloudAgentAutocomplete('no-schedule', {
reason: enableChatAutocomplete ? 'guards-not-met' : 'disabled',
});
}
},
[enableChatAutocomplete, requestCompletion]
);

useEffect(() => {
return () => {
if (completionDebounceRef.current) {
clearTimeout(completionDebounceRef.current);
}
};
}, []);

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