-
-
Notifications
You must be signed in to change notification settings - Fork 13
Add local /clear command support in the composer
#23
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ import { | |
| useRef, | ||
| useEffect, | ||
| useCallback, | ||
| useMemo, | ||
| memo, | ||
| type KeyboardEvent, | ||
| } from "react"; | ||
|
|
@@ -535,6 +536,7 @@ const FOLDER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 | |
|
|
||
| interface InputBarProps { | ||
| onSend: (text: string, images?: ImageAttachment[], displayText?: string) => void; | ||
| onClear?: () => void | Promise<void>; | ||
| onStop: () => void; | ||
| isProcessing: boolean; | ||
| model: string; | ||
|
|
@@ -580,6 +582,39 @@ interface InputBarProps { | |
| isIslandLayout?: boolean; | ||
| } | ||
|
|
||
| export const LOCAL_CLEAR_COMMAND: SlashCommand = { | ||
| name: "clear", | ||
| description: "Open a new chat without sending anything to the agent", | ||
| argumentHint: "", | ||
| source: "local", | ||
| }; | ||
|
|
||
| export function getAvailableSlashCommands(slashCommands?: SlashCommand[]): SlashCommand[] { | ||
| const commands = slashCommands?.filter((cmd) => 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); | ||
|
Comment on lines
+1070
to
+1074
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This branch awaits Useful? React with 👍 / 👎.
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @copilot fix this please |
||
| } | ||
| return; | ||
| } | ||
|
|
||
| // File mentions → <file>/<folder> 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<HTMLDivElement>) => { | ||
| // 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<HTMLDivElement>) => { | ||
|
|
@@ -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"} | ||
| </div> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
/workspace/harnss/AGENTS.mdexplicitly forbids false optionals, but this change makesonClearoptional even though the only runtime path (AppLayout→BottomComposer→InputBar) provides it. Keeping it optional weakens the contract and allows future call sites to render a visible/clearcommand that silently does nothing except clear the composer, which is a maintainability and behavior regression the type system could prevent.Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@copilot fix this please