diff --git a/shared/types/engine.ts b/shared/types/engine.ts index e0e9e8e..403cb95 100644 --- a/shared/types/engine.ts +++ b/shared/types/engine.ts @@ -10,7 +10,7 @@ export interface SlashCommand { /** Placeholder hint for arguments (e.g., ""), shown grayed after the command name. */ argumentHint?: string; /** Engine-specific source type — used for execution routing. */ - source: "claude" | "acp" | "codex-skill" | "codex-app"; + source: "claude" | "acp" | "codex-skill" | "codex-app" | "local"; /** For Codex skills: auto-fill text after the prefix. */ defaultPrompt?: string; /** For Codex apps: the app slug for $app-slug prefix. */ diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index e644637..eede5e4 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -158,7 +158,7 @@ export function AppLayout() { [handleSend], ); - const handleSidebarNewChat = useCallback( + const handleOpenNewChat = useCallback( async (projectId: string) => { const project = projectManager.projects.find((item) => item.id === projectId); if (project) { @@ -169,6 +169,16 @@ export function AppLayout() { [handleNewChat, projectManager.projects, setJiraBoardProjectForSpace], ); + const handleComposerClear = useCallback( + async () => { + const projectId = activeProjectId ?? activeSpaceProject?.id; + if (!projectId) return; + setGrabbedElements([]); + await handleOpenNewChat(projectId); + }, + [activeProjectId, activeSpaceProject, handleOpenNewChat, setGrabbedElements], + ); + const handleSidebarSelectSession = useCallback( (sessionId: string) => { const session = manager.sessions.find((item) => item.id === sessionId); @@ -384,7 +394,7 @@ Link: ${issue.url}`; activeSessionId={manager.activeSessionId} jiraBoardProjectId={jiraBoardProjectId} jiraBoardEnabled={jiraBoardEnabled} - onNewChat={handleSidebarNewChat} + onNewChat={handleOpenNewChat} onToggleProjectJiraBoard={handleToggleProjectJiraBoard} onSelectSession={handleSidebarSelectSession} onDeleteSession={manager.deleteSession} @@ -532,6 +542,7 @@ Link: ${issue.url}`; pendingPermission={manager.pendingPermission} onRespondPermission={manager.respondPermission} onSend={wrappedHandleSend} + onClear={handleComposerClear} onStop={handleStop} isProcessing={manager.isProcessing} queuedCount={manager.queuedCount} diff --git a/src/components/InputBar.test.ts b/src/components/InputBar.test.ts new file mode 100644 index 0000000..a9574f9 --- /dev/null +++ b/src/components/InputBar.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import type { SlashCommand } from "@/types"; +import { + LOCAL_CLEAR_COMMAND, + getAvailableSlashCommands, + getSlashCommandReplacement, + isClearCommandText, +} from "./InputBar"; + +describe("InputBar slash command helpers", () => { + it("always includes the local clear command first", () => { + const commands: SlashCommand[] = [ + { name: "compact", description: "Compact context", source: "claude" }, + ]; + + expect(getAvailableSlashCommands(commands)).toEqual([ + LOCAL_CLEAR_COMMAND, + commands[0], + ]); + }); + + it("deduplicates engine-provided clear commands in favor of the local one", () => { + const commands: SlashCommand[] = [ + { name: "clear", description: "Engine clear", source: "claude" }, + { name: "help", description: "Help", source: "claude" }, + ]; + + expect(getAvailableSlashCommands(commands)).toEqual([ + LOCAL_CLEAR_COMMAND, + commands[1], + ]); + }); + + it("detects the exact /clear command text", () => { + expect(isClearCommandText("/clear")).toBe(true); + expect(isClearCommandText(" /clear ")).toBe(true); + expect(isClearCommandText("/clear now")).toBe(false); + expect(isClearCommandText("/compact")).toBe(false); + }); + + it("builds replacement text for local and engine commands", () => { + expect(getSlashCommandReplacement(LOCAL_CLEAR_COMMAND)).toBe("/clear"); + expect(getSlashCommandReplacement({ name: "compact", description: "", source: "claude" })).toBe("/compact "); + expect(getSlashCommandReplacement({ name: "open", description: "", source: "codex-app", appSlug: "jira" })).toBe("$jira "); + expect( + getSlashCommandReplacement({ name: "fix", description: "", source: "codex-skill", defaultPrompt: "bug" }), + ).toBe("$fix bug"); + }); +}); diff --git a/src/components/InputBar.tsx b/src/components/InputBar.tsx index 3a6dcc8..f4939b7 100644 --- a/src/components/InputBar.tsx +++ b/src/components/InputBar.tsx @@ -3,6 +3,7 @@ import { useRef, useEffect, useCallback, + useMemo, memo, type KeyboardEvent, } from "react"; @@ -535,6 +536,7 @@ const FOLDER_ICON_SVG = ` cmd.name !== LOCAL_CLEAR_COMMAND.name) ?? []; + return [LOCAL_CLEAR_COMMAND, ...commands]; +} + +export function isClearCommandText(text: string): boolean { + return text.trim() === `/${LOCAL_CLEAR_COMMAND.name}`; +} + +export function getSlashCommandReplacement(cmd: SlashCommand): string { + switch (cmd.source) { + case "claude": + case "acp": + return `/${cmd.name} `; + case "codex-skill": + return cmd.defaultPrompt + ? `$${cmd.name} ${cmd.defaultPrompt}` + : `$${cmd.name} `; + case "codex-app": + return `$${cmd.appSlug ?? cmd.name} `; + case "local": + // Local commands execute directly, so keep the exact command text with no trailing space. + return `/${cmd.name}`; + } +} + // Simple fuzzy match: all query chars must appear in order function fuzzyMatch(query: string, target: string): { match: boolean; score: number } { const q = query.toLowerCase(); @@ -688,6 +723,7 @@ function extractEditableContent(el: HTMLElement): { text: string; mentionPaths: export const InputBar = memo(function InputBar({ onSend, + onClear, onStop, isProcessing, model, @@ -758,6 +794,10 @@ export const InputBar = memo(function InputBar({ const isACPAgent = selectedAgent != null && selectedAgent.engine === "acp"; const isCodexAgent = selectedAgent != null && selectedAgent.engine === "codex"; const showACPConfigOptions = isACPAgent && (acpConfigOptions?.length ?? 0) > 0; + const availableSlashCommands = useMemo( + () => getAvailableSlashCommands(slashCommands), + [slashCommands], + ); const isAwaitingAcpOptions = isACPAgent && !!acpConfigOptionsLoading; const modelsLoading = modelList.length === 0; const modelsLoadingText = isCodexAgent @@ -885,10 +925,10 @@ export const InputBar = memo(function InputBar({ // Slash command filtered results const cmdResults = (() => { - if (!showCommands || !slashCommands?.length) return []; + if (!showCommands || availableSlashCommands.length === 0) return []; const q = commandQuery.toLowerCase(); - if (!q) return slashCommands.slice(0, 15); - return slashCommands + if (!q) return availableSlashCommands.slice(0, 15); + return availableSlashCommands .filter(cmd => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q)) .slice(0, 15); })(); @@ -915,6 +955,15 @@ export const InputBar = memo(function InputBar({ mentionStartOffset.current = 0; }, []); + const clearComposer = useCallback((el: HTMLDivElement) => { + el.innerHTML = ""; + hasContentRef.current = false; + setHasContent(false); + setAttachments([]); + closeMentions(); + setShowCommands(false); + }, [closeMentions]); + const addImageFiles = useCallback(async (files: FileList | globalThis.File[]) => { const validFiles = Array.from(files).filter(isAcceptedImage); if (validFiles.length === 0) return; @@ -941,24 +990,7 @@ export const InputBar = memo(function InputBar({ const el = editableRef.current; if (!el) return; - // Build the replacement text based on source engine - let replacement: string; - switch (cmd.source) { - case "claude": - case "acp": - replacement = `/${cmd.name} `; - break; - case "codex-skill": - replacement = cmd.defaultPrompt - ? `$${cmd.name} ${cmd.defaultPrompt}` - : `$${cmd.name} `; - break; - case "codex-app": - replacement = `$${cmd.appSlug ?? cmd.name} `; - break; - } - - el.textContent = replacement; + el.textContent = getSlashCommandReplacement(cmd); // Move cursor to end const range = document.createRange(); @@ -1035,6 +1067,15 @@ export const InputBar = memo(function InputBar({ const grabbedElementDisplayTokens: string[] = []; let hasContext = false; + if (isClearCommandText(trimmed)) { + try { + await onClear?.(); + } finally { + clearComposer(el); + } + return; + } + // File mentions → / context blocks if (mentionPaths.length > 0 && projectPath) { setIsSending(true); @@ -1104,13 +1145,8 @@ export const InputBar = memo(function InputBar({ onSend(trimmed, currentImages); } - // Clear input - el.innerHTML = ""; - hasContentRef.current = false; - setHasContent(false); - setAttachments([]); - closeMentions(); - }, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, closeMentions, grabbedElements]); + clearComposer(el); + }, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, onClear, clearComposer, grabbedElements]); const handleKeyDown = (e: KeyboardEvent) => { // Slash command picker keyboard navigation @@ -1245,14 +1281,14 @@ export const InputBar = memo(function InputBar({ // Slash command detection — "/" at position 0 with no spaces (still typing the command name) const fullText = (el.textContent ?? "").trimStart(); const slashMatch = fullText.match(/^\/(\S*)$/); - if (slashMatch && slashCommands?.length) { + if (slashMatch && availableSlashCommands.length > 0) { setShowCommands(true); setCommandQuery(slashMatch[1]); setCommandIndex(0); } else if (showCommands) { setShowCommands(false); } - }, [showMentions, showCommands, closeMentions, projectPath, slashCommands]); + }, [showMentions, showCommands, closeMentions, projectPath, availableSlashCommands]); const handlePaste = useCallback( (e: React.ClipboardEvent) => { @@ -1433,7 +1469,7 @@ export const InputBar = memo(function InputBar({ ? "Loading agent options..." : isProcessing ? `${selectedAgent?.name ?? "Claude"} is responding... (messages will be queued)` - : slashCommands?.length + : availableSlashCommands.length > 0 ? "Ask anything, @ to tag files, / for commands" : "Ask anything, @ to tag files"}