diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.ts b/apps/mobile/src/features/threads/use-composer-command-menu.ts index e6cfe6d52256..48b05457de6d 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -16,6 +16,7 @@ import { } from "../../state/use-composer-drafts"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { + composerTriggerIsOnlyText, detectComposerTrigger, replaceTextRange, serializeComposerFileLink, @@ -534,7 +535,8 @@ export function useComposerCommandMenu({ if ( item.type === "provider-slash-command" && item.command.argumentMode === "none" && - onProviderCommand + onProviderCommand && + composerTriggerIsOnlyText(draftMessage, trigger.rangeStart, trigger.rangeEnd) ) { const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); setSelection({ start: cleared.cursor, end: cleared.cursor }); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index b4967db4287b..411bfe1a9885 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -48,7 +48,10 @@ import { pastedTextDisposition, wouldTextPasteExceedLimit, } from "@t3tools/client-runtime/text-paste"; -import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; +import { + composerTriggerIsOnlyText, + serializeComposerFileLink, +} from "@t3tools/shared/composerTrigger"; import { folderDropTarget, resolveDroppedFolderPath } from "./folderDrop"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; @@ -3640,7 +3643,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } return; } - if (item.command.argumentMode === "none" && onProviderCommand) { + if ( + item.command.argumentMode === "none" && + onProviderCommand && + composerTriggerIsOnlyText(snapshot.value, trigger.rangeStart, trigger.rangeEnd) + ) { const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), }); diff --git a/packages/shared/src/composerTrigger.test.ts b/packages/shared/src/composerTrigger.test.ts index 06b7038dad2b..a70fd255c619 100644 --- a/packages/shared/src/composerTrigger.test.ts +++ b/packages/shared/src/composerTrigger.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vite-plus/test"; -import { detectComposerTrigger, serializeComposerFileLink } from "./composerTrigger.ts"; +import { + composerTriggerIsOnlyText, + detectComposerTrigger, + serializeComposerFileLink, +} from "./composerTrigger.ts"; describe("detectComposerTrigger", () => { it.each(["$", "€", "£", "¥", "₹", "₩", "₿", "𑿝"])( @@ -17,6 +21,18 @@ describe("detectComposerTrigger", () => { ); }); +describe("composerTriggerIsOnlyText", () => { + it("accepts a standalone slash-command trigger", () => { + expect(composerTriggerIsOnlyText("/com", 0, 4)).toBe(true); + expect(composerTriggerIsOnlyText(" /com ", 2, 6)).toBe(true); + }); + + it("rejects a trigger when other draft text would remain", () => { + expect(composerTriggerIsOnlyText("/com keep this draft", 0, 4)).toBe(false); + expect(composerTriggerIsOnlyText("keep /com", 5, 9)).toBe(false); + }); +}); + describe("serializeComposerFileLink", () => { it("uses the basename as the markdown label", () => { expect(serializeComposerFileLink("path/to/package.json")).toBe( diff --git a/packages/shared/src/composerTrigger.ts b/packages/shared/src/composerTrigger.ts index 6153978edb92..f5215ae8b487 100644 --- a/packages/shared/src/composerTrigger.ts +++ b/packages/shared/src/composerTrigger.ts @@ -140,3 +140,17 @@ export function replaceTextRange( const nextText = `${text.slice(0, safeStart)}${replacement}${text.slice(safeEnd)}`; return { text: nextText, cursor: safeStart + replacement.length }; } + + +/** + * True when removing the active autocomplete trigger leaves no other prompt text. + * Used by no-argument provider commands so selecting a command only auto-runs a + * standalone command and never discards surrounding draft text. + */ +export function composerTriggerIsOnlyText( + text: string, + rangeStart: number, + rangeEnd: number, +): boolean { + return replaceTextRange(text, rangeStart, rangeEnd, "").text.trim().length === 0; +}