Skip to content

Commit 9dac827

Browse files
Add local /clear command support in the composer (#23)
* Initial plan * feat: add local /clear command support Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: OpenSource03 <29690431+OpenSource03@users.noreply.github.com>
1 parent 1b90269 commit 9dac827

4 files changed

Lines changed: 130 additions & 34 deletions

File tree

shared/types/engine.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export interface SlashCommand {
1010
/** Placeholder hint for arguments (e.g., "<query>"), shown grayed after the command name. */
1111
argumentHint?: string;
1212
/** Engine-specific source type — used for execution routing. */
13-
source: "claude" | "acp" | "codex-skill" | "codex-app";
13+
source: "claude" | "acp" | "codex-skill" | "codex-app" | "local";
1414
/** For Codex skills: auto-fill text after the prefix. */
1515
defaultPrompt?: string;
1616
/** For Codex apps: the app slug for $app-slug prefix. */

src/components/AppLayout.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ export function AppLayout() {
158158
[handleSend],
159159
);
160160

161-
const handleSidebarNewChat = useCallback(
161+
const handleOpenNewChat = useCallback(
162162
async (projectId: string) => {
163163
const project = projectManager.projects.find((item) => item.id === projectId);
164164
if (project) {
@@ -169,6 +169,16 @@ export function AppLayout() {
169169
[handleNewChat, projectManager.projects, setJiraBoardProjectForSpace],
170170
);
171171

172+
const handleComposerClear = useCallback(
173+
async () => {
174+
const projectId = activeProjectId ?? activeSpaceProject?.id;
175+
if (!projectId) return;
176+
setGrabbedElements([]);
177+
await handleOpenNewChat(projectId);
178+
},
179+
[activeProjectId, activeSpaceProject, handleOpenNewChat, setGrabbedElements],
180+
);
181+
172182
const handleSidebarSelectSession = useCallback(
173183
(sessionId: string) => {
174184
const session = manager.sessions.find((item) => item.id === sessionId);
@@ -384,7 +394,7 @@ Link: ${issue.url}`;
384394
activeSessionId={manager.activeSessionId}
385395
jiraBoardProjectId={jiraBoardProjectId}
386396
jiraBoardEnabled={jiraBoardEnabled}
387-
onNewChat={handleSidebarNewChat}
397+
onNewChat={handleOpenNewChat}
388398
onToggleProjectJiraBoard={handleToggleProjectJiraBoard}
389399
onSelectSession={handleSidebarSelectSession}
390400
onDeleteSession={manager.deleteSession}
@@ -532,6 +542,7 @@ Link: ${issue.url}`;
532542
pendingPermission={manager.pendingPermission}
533543
onRespondPermission={manager.respondPermission}
534544
onSend={wrappedHandleSend}
545+
onClear={handleComposerClear}
535546
onStop={handleStop}
536547
isProcessing={manager.isProcessing}
537548
queuedCount={manager.queuedCount}

src/components/InputBar.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, it } from "vitest";
2+
import type { SlashCommand } from "@/types";
3+
import {
4+
LOCAL_CLEAR_COMMAND,
5+
getAvailableSlashCommands,
6+
getSlashCommandReplacement,
7+
isClearCommandText,
8+
} from "./InputBar";
9+
10+
describe("InputBar slash command helpers", () => {
11+
it("always includes the local clear command first", () => {
12+
const commands: SlashCommand[] = [
13+
{ name: "compact", description: "Compact context", source: "claude" },
14+
];
15+
16+
expect(getAvailableSlashCommands(commands)).toEqual([
17+
LOCAL_CLEAR_COMMAND,
18+
commands[0],
19+
]);
20+
});
21+
22+
it("deduplicates engine-provided clear commands in favor of the local one", () => {
23+
const commands: SlashCommand[] = [
24+
{ name: "clear", description: "Engine clear", source: "claude" },
25+
{ name: "help", description: "Help", source: "claude" },
26+
];
27+
28+
expect(getAvailableSlashCommands(commands)).toEqual([
29+
LOCAL_CLEAR_COMMAND,
30+
commands[1],
31+
]);
32+
});
33+
34+
it("detects the exact /clear command text", () => {
35+
expect(isClearCommandText("/clear")).toBe(true);
36+
expect(isClearCommandText(" /clear ")).toBe(true);
37+
expect(isClearCommandText("/clear now")).toBe(false);
38+
expect(isClearCommandText("/compact")).toBe(false);
39+
});
40+
41+
it("builds replacement text for local and engine commands", () => {
42+
expect(getSlashCommandReplacement(LOCAL_CLEAR_COMMAND)).toBe("/clear");
43+
expect(getSlashCommandReplacement({ name: "compact", description: "", source: "claude" })).toBe("/compact ");
44+
expect(getSlashCommandReplacement({ name: "open", description: "", source: "codex-app", appSlug: "jira" })).toBe("$jira ");
45+
expect(
46+
getSlashCommandReplacement({ name: "fix", description: "", source: "codex-skill", defaultPrompt: "bug" }),
47+
).toBe("$fix bug");
48+
});
49+
});

src/components/InputBar.tsx

Lines changed: 67 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
useRef,
44
useEffect,
55
useCallback,
6+
useMemo,
67
memo,
78
type KeyboardEvent,
89
} from "react";
@@ -535,6 +536,7 @@ const FOLDER_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24
535536

536537
interface InputBarProps {
537538
onSend: (text: string, images?: ImageAttachment[], displayText?: string) => void;
539+
onClear?: () => void | Promise<void>;
538540
onStop: () => void;
539541
isProcessing: boolean;
540542
model: string;
@@ -580,6 +582,39 @@ interface InputBarProps {
580582
isIslandLayout?: boolean;
581583
}
582584

585+
export const LOCAL_CLEAR_COMMAND: SlashCommand = {
586+
name: "clear",
587+
description: "Open a new chat without sending anything to the agent",
588+
argumentHint: "",
589+
source: "local",
590+
};
591+
592+
export function getAvailableSlashCommands(slashCommands?: SlashCommand[]): SlashCommand[] {
593+
const commands = slashCommands?.filter((cmd) => cmd.name !== LOCAL_CLEAR_COMMAND.name) ?? [];
594+
return [LOCAL_CLEAR_COMMAND, ...commands];
595+
}
596+
597+
export function isClearCommandText(text: string): boolean {
598+
return text.trim() === `/${LOCAL_CLEAR_COMMAND.name}`;
599+
}
600+
601+
export function getSlashCommandReplacement(cmd: SlashCommand): string {
602+
switch (cmd.source) {
603+
case "claude":
604+
case "acp":
605+
return `/${cmd.name} `;
606+
case "codex-skill":
607+
return cmd.defaultPrompt
608+
? `$${cmd.name} ${cmd.defaultPrompt}`
609+
: `$${cmd.name} `;
610+
case "codex-app":
611+
return `$${cmd.appSlug ?? cmd.name} `;
612+
case "local":
613+
// Local commands execute directly, so keep the exact command text with no trailing space.
614+
return `/${cmd.name}`;
615+
}
616+
}
617+
583618
// Simple fuzzy match: all query chars must appear in order
584619
function fuzzyMatch(query: string, target: string): { match: boolean; score: number } {
585620
const q = query.toLowerCase();
@@ -688,6 +723,7 @@ function extractEditableContent(el: HTMLElement): { text: string; mentionPaths:
688723

689724
export const InputBar = memo(function InputBar({
690725
onSend,
726+
onClear,
691727
onStop,
692728
isProcessing,
693729
model,
@@ -758,6 +794,10 @@ export const InputBar = memo(function InputBar({
758794
const isACPAgent = selectedAgent != null && selectedAgent.engine === "acp";
759795
const isCodexAgent = selectedAgent != null && selectedAgent.engine === "codex";
760796
const showACPConfigOptions = isACPAgent && (acpConfigOptions?.length ?? 0) > 0;
797+
const availableSlashCommands = useMemo(
798+
() => getAvailableSlashCommands(slashCommands),
799+
[slashCommands],
800+
);
761801
const isAwaitingAcpOptions = isACPAgent && !!acpConfigOptionsLoading;
762802
const modelsLoading = modelList.length === 0;
763803
const modelsLoadingText = isCodexAgent
@@ -885,10 +925,10 @@ export const InputBar = memo(function InputBar({
885925

886926
// Slash command filtered results
887927
const cmdResults = (() => {
888-
if (!showCommands || !slashCommands?.length) return [];
928+
if (!showCommands || availableSlashCommands.length === 0) return [];
889929
const q = commandQuery.toLowerCase();
890-
if (!q) return slashCommands.slice(0, 15);
891-
return slashCommands
930+
if (!q) return availableSlashCommands.slice(0, 15);
931+
return availableSlashCommands
892932
.filter(cmd => cmd.name.toLowerCase().includes(q) || cmd.description.toLowerCase().includes(q))
893933
.slice(0, 15);
894934
})();
@@ -915,6 +955,15 @@ export const InputBar = memo(function InputBar({
915955
mentionStartOffset.current = 0;
916956
}, []);
917957

958+
const clearComposer = useCallback((el: HTMLDivElement) => {
959+
el.innerHTML = "";
960+
hasContentRef.current = false;
961+
setHasContent(false);
962+
setAttachments([]);
963+
closeMentions();
964+
setShowCommands(false);
965+
}, [closeMentions]);
966+
918967
const addImageFiles = useCallback(async (files: FileList | globalThis.File[]) => {
919968
const validFiles = Array.from(files).filter(isAcceptedImage);
920969
if (validFiles.length === 0) return;
@@ -941,24 +990,7 @@ export const InputBar = memo(function InputBar({
941990
const el = editableRef.current;
942991
if (!el) return;
943992

944-
// Build the replacement text based on source engine
945-
let replacement: string;
946-
switch (cmd.source) {
947-
case "claude":
948-
case "acp":
949-
replacement = `/${cmd.name} `;
950-
break;
951-
case "codex-skill":
952-
replacement = cmd.defaultPrompt
953-
? `$${cmd.name} ${cmd.defaultPrompt}`
954-
: `$${cmd.name} `;
955-
break;
956-
case "codex-app":
957-
replacement = `$${cmd.appSlug ?? cmd.name} `;
958-
break;
959-
}
960-
961-
el.textContent = replacement;
993+
el.textContent = getSlashCommandReplacement(cmd);
962994

963995
// Move cursor to end
964996
const range = document.createRange();
@@ -1035,6 +1067,15 @@ export const InputBar = memo(function InputBar({
10351067
const grabbedElementDisplayTokens: string[] = [];
10361068
let hasContext = false;
10371069

1070+
if (isClearCommandText(trimmed)) {
1071+
try {
1072+
await onClear?.();
1073+
} finally {
1074+
clearComposer(el);
1075+
}
1076+
return;
1077+
}
1078+
10381079
// File mentions → <file>/<folder> context blocks
10391080
if (mentionPaths.length > 0 && projectPath) {
10401081
setIsSending(true);
@@ -1104,13 +1145,8 @@ export const InputBar = memo(function InputBar({
11041145
onSend(trimmed, currentImages);
11051146
}
11061147

1107-
// Clear input
1108-
el.innerHTML = "";
1109-
hasContentRef.current = false;
1110-
setHasContent(false);
1111-
setAttachments([]);
1112-
closeMentions();
1113-
}, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, closeMentions, grabbedElements]);
1148+
clearComposer(el);
1149+
}, [attachments, isAwaitingAcpOptions, isSending, projectPath, onSend, onClear, clearComposer, grabbedElements]);
11141150

11151151
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
11161152
// Slash command picker keyboard navigation
@@ -1245,14 +1281,14 @@ export const InputBar = memo(function InputBar({
12451281
// Slash command detection — "/" at position 0 with no spaces (still typing the command name)
12461282
const fullText = (el.textContent ?? "").trimStart();
12471283
const slashMatch = fullText.match(/^\/(\S*)$/);
1248-
if (slashMatch && slashCommands?.length) {
1284+
if (slashMatch && availableSlashCommands.length > 0) {
12491285
setShowCommands(true);
12501286
setCommandQuery(slashMatch[1]);
12511287
setCommandIndex(0);
12521288
} else if (showCommands) {
12531289
setShowCommands(false);
12541290
}
1255-
}, [showMentions, showCommands, closeMentions, projectPath, slashCommands]);
1291+
}, [showMentions, showCommands, closeMentions, projectPath, availableSlashCommands]);
12561292

12571293
const handlePaste = useCallback(
12581294
(e: React.ClipboardEvent<HTMLDivElement>) => {
@@ -1433,7 +1469,7 @@ export const InputBar = memo(function InputBar({
14331469
? "Loading agent options..."
14341470
: isProcessing
14351471
? `${selectedAgent?.name ?? "Claude"} is responding... (messages will be queued)`
1436-
: slashCommands?.length
1472+
: availableSlashCommands.length > 0
14371473
? "Ask anything, @ to tag files, / for commands"
14381474
: "Ask anything, @ to tag files"}
14391475
</div>

0 commit comments

Comments
 (0)