Skip to content
Open
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
5 changes: 5 additions & 0 deletions .cmux/review.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
Please review the following code with attention to:
- Code quality and readability
- Potential bugs or edge cases
- Performance considerations
- Best practices
4 changes: 4 additions & 0 deletions src/App.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@ function setupMockAPI(options: {
install: () => undefined,
onStatus: () => () => undefined,
},
prompts: {
list: () => Promise.resolve([]),
read: () => Promise.resolve(null),
},
...options.apiOverrides,
};

Expand Down
5 changes: 5 additions & 0 deletions src/browser/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ const webApi: IPCApi = {
return wsManager.on(IPC_CHANNELS.UPDATE_STATUS, callback as (data: unknown) => void);
},
},
prompts: {
list: (workspaceId) => invokeIPC(IPC_CHANNELS.PROMPTS_LIST, workspaceId),
read: (workspaceId, promptName) =>
invokeIPC(IPC_CHANNELS.PROMPTS_READ, workspaceId, promptName),
},
};

if (typeof window.api === "undefined") {
Expand Down
98 changes: 93 additions & 5 deletions src/components/ChatInput.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React, { useState, useRef, useCallback, useEffect, useId } from "react";
import { cn } from "@/lib/utils";
import { CommandSuggestions, COMMAND_SUGGESTION_KEYS } from "./CommandSuggestions";
import { PromptSuggestions, PROMPT_SUGGESTION_KEYS } from "./PromptSuggestions";
import type { Toast } from "./ChatInputToast";
import { ChatInputToast } from "./ChatInputToast";
import { createCommandToast, createErrorToast } from "./ChatInputToasts";
Expand All @@ -24,6 +25,8 @@ import {
getSlashCommandSuggestions,
type SlashSuggestion,
} from "@/utils/slashCommands/suggestions";
import { usePrompts } from "@/hooks/usePrompts";
import type { PromptSuggestion } from "@/utils/promptSuggestions";
import { TooltipWrapper, Tooltip, HelpIndicator } from "./Tooltip";
import { matchesKeybind, formatKeybind, KEYBINDS, isEditableElement } from "@/utils/ui/keybinds";
import { ModelSelector, type ModelSelectorRef } from "./ModelSelector";
Expand Down Expand Up @@ -85,14 +88,25 @@ export const ChatInput: React.FC<ChatInputProps> = ({
const [providerNames, setProviderNames] = useState<string[]>([]);
const [toast, setToast] = useState<Toast | null>(null);
const [imageAttachments, setImageAttachments] = useState<ImageAttachment[]>([]);

const handleToastDismiss = useCallback(() => {
setToast(null);
}, []);
const inputRef = useRef<HTMLTextAreaElement>(null);
const modelSelectorRef = useRef<ModelSelectorRef>(null);

// Use the prompts hook to handle all prompt-related logic
const cursorPos = inputRef.current?.selectionStart ?? input.length;
const {
suggestions: promptSuggestions,
showSuggestions: showPromptSuggestions,
dismissSuggestions: dismissPromptSuggestions,
expandMentions: expandPromptMentions,
} = usePrompts({ workspaceId, input, cursorPos });
const [mode, setMode] = useMode();
const { recentModels, addModel } = useModelLRU();
const commandListId = useId();
const promptListId = useId();
const telemetry = useTelemetry();

// Get current send message options from shared hook (must be at component top level)
Expand Down Expand Up @@ -339,13 +353,57 @@ export const ChatInput: React.FC<ChatInputProps> = ({
[setInput]
);

const handlePromptSelect = useCallback(
(suggestion: PromptSuggestion) => {
// Replace the "@partial" at cursor position with "@full-name"
const textarea = inputRef.current;
if (!textarea) return;

const cursorPos = textarea.selectionStart;
const textBeforeCursor = input.slice(0, cursorPos);

// Find the last @ before the cursor
const lastAtIndex = textBeforeCursor.lastIndexOf("@");
if (lastAtIndex === -1) return;

// Extract text after the @
const textAfter = input.slice(lastAtIndex + 1);

// Find where the partial mention ends (space, newline, or end of string)
const endMatch = /[\s\n]/.exec(textAfter);
const endIndex =
endMatch?.index !== undefined ? lastAtIndex + 1 + endMatch.index : input.length;

// Build the new input with the completed mention
const before = input.slice(0, lastAtIndex);
const after = input.slice(endIndex);
const newInput = `${before}${suggestion.replacement}${after}`;

setInput(newInput);

// Set cursor position after the completed mention
const newCursorPos = before.length + suggestion.replacement.length;
setTimeout(() => {
if (textarea) {
textarea.selectionStart = newCursorPos;
textarea.selectionEnd = newCursorPos;
textarea.focus();
}
}, 0);
},
[input, setInput]
);

const handleSend = async () => {
// Allow sending if there's text or images
if ((!input.trim() && imageAttachments.length === 0) || disabled || isSending || isCompacting) {
return;
}

const messageText = input.trim();
let messageText = input.trim();

// Expand prompt mentions before sending
messageText = await expandPromptMentions(messageText);

try {
// Parse command
Expand Down Expand Up @@ -663,7 +721,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return;
}

// Note: ESC handled by VimTextArea (for mode transitions) and CommandSuggestions (for dismissal)
// Note: ESC handled by VimTextArea (for mode transitions), CommandSuggestions, and PromptSuggestions (for dismissal)
// Edit canceling is Ctrl+Q, stream interruption is Ctrl+C

// Don't handle keys if command suggestions are visible
Expand All @@ -675,6 +733,15 @@ export const ChatInput: React.FC<ChatInputProps> = ({
return; // Let CommandSuggestions handle it
}

// Don't handle keys if prompt suggestions are visible
if (
showPromptSuggestions &&
promptSuggestions.length > 0 &&
PROMPT_SUGGESTION_KEYS.includes(e.key)
) {
return; // Let PromptSuggestions handle it
}

// Handle send message (Shift+Enter for newline is default behavior)
if (matchesKeybind(e, KEYBINDS.SEND_MESSAGE)) {
e.preventDefault();
Expand Down Expand Up @@ -717,6 +784,14 @@ export const ChatInput: React.FC<ChatInputProps> = ({
ariaLabel="Slash command suggestions"
listId={commandListId}
/>
<PromptSuggestions
suggestions={promptSuggestions}
onSelectSuggestion={handlePromptSelect}
onDismiss={dismissPromptSuggestions}
isVisible={showPromptSuggestions}
ariaLabel="Prompt mention suggestions"
listId={promptListId}
/>
<div className="flex items-end gap-2.5" data-component="ChatInputControls">
<VimTextArea
ref={inputRef}
Expand All @@ -728,15 +803,28 @@ export const ChatInput: React.FC<ChatInputProps> = ({
onPaste={handlePaste}
onDragOver={handleDragOver}
onDrop={handleDrop}
suppressKeys={showCommandSuggestions ? COMMAND_SUGGESTION_KEYS : undefined}
suppressKeys={
showCommandSuggestions
? COMMAND_SUGGESTION_KEYS
: showPromptSuggestions
? PROMPT_SUGGESTION_KEYS
: undefined
}
placeholder={placeholder}
disabled={!editingMessage && (disabled || isSending || isCompacting)}
aria-label={editingMessage ? "Edit your last message" : "Message Claude"}
aria-autocomplete="list"
aria-controls={
showCommandSuggestions && commandSuggestions.length > 0 ? commandListId : undefined
showCommandSuggestions && commandSuggestions.length > 0
? commandListId
: showPromptSuggestions && promptSuggestions.length > 0
? promptListId
: undefined
}
aria-expanded={
(showCommandSuggestions && commandSuggestions.length > 0) ||
(showPromptSuggestions && promptSuggestions.length > 0)
}
aria-expanded={showCommandSuggestions && commandSuggestions.length > 0}
/>
</div>
<ImageAttachments images={imageAttachments} onRemove={handleRemoveImage} />
Expand Down
127 changes: 127 additions & 0 deletions src/components/PromptSuggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import React, { useState, useEffect } from "react";
import { cn } from "@/lib/utils";
import type { PromptSuggestion } from "@/utils/promptSuggestions";

// Export the keys that PromptSuggestions handles
export const PROMPT_SUGGESTION_KEYS = ["Tab", "ArrowUp", "ArrowDown", "Escape"];

// Props interface
interface PromptSuggestionsProps {
suggestions: PromptSuggestion[];
onSelectSuggestion: (suggestion: PromptSuggestion) => void;
onDismiss: () => void;
isVisible: boolean;
ariaLabel?: string;
listId?: string;
}

// Main component
export const PromptSuggestions: React.FC<PromptSuggestionsProps> = ({
suggestions,
onSelectSuggestion,
onDismiss,
isVisible,
ariaLabel = "Prompt suggestions",
listId,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);

// Reset selection whenever suggestions change
useEffect(() => {
setSelectedIndex(0);
}, [suggestions]);

// Handle keyboard navigation
useEffect(() => {
if (!isVisible || suggestions.length === 0) return;

const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setSelectedIndex((i) => (i + 1) % suggestions.length);
break;
case "ArrowUp":
e.preventDefault();
setSelectedIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
break;
case "Tab":
if (!e.shiftKey && suggestions.length > 0) {
e.preventDefault();
onSelectSuggestion(suggestions[selectedIndex]);
}
break;
case "Escape":
e.preventDefault();
onDismiss();
break;
}
};

document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isVisible, suggestions, selectedIndex, onSelectSuggestion, onDismiss]);

// Click outside handler
useEffect(() => {
if (!isVisible) return;

const handleClickOutside = (e: MouseEvent) => {
const target = e.target as HTMLElement;
if (!target.closest("[data-prompt-suggestions]")) {
onDismiss();
}
};

document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [isVisible, onDismiss]);

if (!isVisible || suggestions.length === 0) {
return null;
}

const activeSuggestion = suggestions[selectedIndex] ?? suggestions[0];
const resolvedListId = listId ?? `prompt-suggestions-list`;

return (
<div
id={resolvedListId}
role="listbox"
aria-label={ariaLabel}
aria-activedescendant={
activeSuggestion ? `${resolvedListId}-option-${activeSuggestion.id}` : undefined
}
data-prompt-suggestions
className="bg-separator border-border-light absolute right-0 bottom-full left-0 z-[100] mb-2 flex max-h-[200px] flex-col overflow-y-auto rounded border shadow-[0_-4px_12px_rgba(0,0,0,0.4)]"
>
{suggestions.map((suggestion, index) => (
<div
key={suggestion.id}
onMouseEnter={() => setSelectedIndex(index)}
onClick={() => onSelectSuggestion(suggestion)}
id={`${resolvedListId}-option-${suggestion.id}`}
role="option"
aria-selected={index === selectedIndex}
className={cn(
"px-2.5 py-1.5 cursor-pointer transition-colors duration-150 flex items-center justify-between gap-3 hover:bg-accent-darker",
index === selectedIndex ? "bg-accent-darker" : "bg-transparent"
)}
>
<div className="text-accent font-monospace shrink-0 text-xs">@{suggestion.name}</div>
<div
className={cn(
"text-medium truncate text-right text-[11px]",
suggestion.location === "repo" && "text-accent-lighter"
)}
>
{suggestion.location === "repo" ? "📁 repo" : "🏠 system"}
</div>
</div>
))}
<div className="border-border-light bg-dark text-placeholder [&_span]:text-medium shrink-0 border-t px-2.5 py-1 text-center text-[10px] [&_span]:font-medium">
<span>Tab</span> to complete • <span>↑↓</span> to navigate • <span>Esc</span> to dismiss
</div>
</div>
);
};
4 changes: 4 additions & 0 deletions src/constants/ipc-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ export const IPC_CHANNELS = {
UPDATE_STATUS: "update:status",
UPDATE_STATUS_SUBSCRIBE: "update:status:subscribe",

// Prompt channels
PROMPTS_LIST: "prompts:list",
PROMPTS_READ: "prompts:read",

// Dynamic channel prefixes
WORKSPACE_CHAT_PREFIX: "workspace:chat:",
WORKSPACE_METADATA: "workspace:metadata",
Expand Down
Loading
Loading