From 6de1b63473a6c4a2479a51dbf6f21fa254fa9cb8 Mon Sep 17 00:00:00 2001 From: Aleksander Grygier Date: Fri, 7 Aug 2026 20:20:01 +0200 Subject: [PATCH] allozaur/feat/chat slash commands (#26716) * base : slash-command/misc foundation - model icon and focus-selector constants * feat : slash-command picker and command parsing helpers * refactor : wire command and @-mention pickers into the chat form * ui : improve model selector keyboard navigation and load/dismiss * feat: Unify markdown/raw-text rendering under one setting with migration * fix: Misc fixes - tool-call subtitle, assistant wrap, progress guards * feat: Clamp and style numeric settings inputs from registry bounds --- tools/ui/src/app.d.ts | 6 +- .../app/chat/ChatForm/ChatForm.svelte | 204 +++----- .../ChatFormCommandPicker.svelte | 135 +++++ .../ChatFormPickerMcpPrompts.svelte | 6 + .../ChatFormPickers/ChatFormPickers.svelte | 37 +- .../app/chat/ChatForm/ChatFormTextarea.svelte | 5 +- .../ChatForm/ChatFormWorkingDirectory.svelte | 466 +++++++----------- .../ChatFormWorkingDirectoryChip.svelte | 3 +- .../ChatMessageAssistant.svelte | 8 +- .../ChatMessageSystem.svelte | 2 +- .../ChatMessageToolCall/ToolCallBlock.svelte | 5 +- .../ChatMessageUserBubble.svelte | 2 +- .../ChatMessageAgenticContent.svelte | 2 - .../ChatMessageReasoningBlock.svelte | 7 +- tools/ui/src/lib/components/app/chat/index.ts | 20 +- .../app/models/ModelsSelectorDropdown.svelte | 114 +++-- .../app/models/ModelsSelectorOption.svelte | 7 +- .../settings/SettingsChat/SettingsChat.svelte | 7 +- .../SettingsChat/SettingsChatFields.svelte | 10 +- tools/ui/src/lib/constants/chat-commands.ts | 44 ++ tools/ui/src/lib/constants/css-classes.ts | 3 + tools/ui/src/lib/constants/index.ts | 1 + tools/ui/src/lib/constants/settings-keys.ts | 3 +- .../ui/src/lib/constants/settings-registry.ts | 17 +- tools/ui/src/lib/constants/ui.ts | 5 +- .../ui/src/lib/constants/working-directory.ts | 3 + tools/ui/src/lib/enums/chat.enums.ts | 6 + tools/ui/src/lib/enums/index.ts | 1 + .../lib/hooks/use-chat-form-pickers.svelte.ts | 355 +++++++++++++ .../lib/hooks/use-models-selector.svelte.ts | 7 +- tools/ui/src/lib/stores/settings.svelte.ts | 24 + tools/ui/src/lib/types/chat.d.ts | 16 +- tools/ui/src/lib/types/index.ts | 3 +- tools/ui/src/lib/utils/command-token.ts | 31 ++ tools/ui/src/lib/utils/index.ts | 7 + tools/ui/src/lib/utils/progress.ts | 6 +- ...at-form-mention-picker-gate.svelte.test.ts | 64 +++ .../chat-form-slash-commands.svelte.test.ts | 122 +++++ .../components/ChatFormPickersHarness.svelte | 51 ++ ...settings-raw-text-migration.svelte.test.ts | 65 +++ tools/ui/tests/unit/command-token.test.ts | 51 ++ 41 files changed, 1425 insertions(+), 506 deletions(-) create mode 100644 tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte create mode 100644 tools/ui/src/lib/constants/chat-commands.ts create mode 100644 tools/ui/src/lib/hooks/use-chat-form-pickers.svelte.ts create mode 100644 tools/ui/src/lib/utils/command-token.ts create mode 100644 tools/ui/tests/client/chat-form-mention-picker-gate.svelte.test.ts create mode 100644 tools/ui/tests/client/chat-form-slash-commands.svelte.test.ts create mode 100644 tools/ui/tests/client/components/ChatFormPickersHarness.svelte create mode 100644 tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts create mode 100644 tools/ui/tests/unit/command-token.test.ts diff --git a/tools/ui/src/app.d.ts b/tools/ui/src/app.d.ts index b9484d9503..814fcf5231 100644 --- a/tools/ui/src/app.d.ts +++ b/tools/ui/src/app.d.ts @@ -143,10 +143,8 @@ declare global { idxThemeStyle?: number; idxCodeBlock?: number; - // File System Access API - missing from older DOM lib versions. - // Used by ChatFormWorkingDirectory's native folder picker. Feature availability - // is gated at runtime via `typeof window.showDirectoryPicker === 'function'`. - showDirectoryPicker: (options?: { + // File System Access API - not in the DOM lib and unavailable in some browsers + showDirectoryPicker?: (options?: { id?: string; mode?: 'read' | 'readwrite'; startIn?: FileSystemHandle | string; diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte index 22406e0722..fa68765efb 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatForm.svelte @@ -14,8 +14,7 @@ INPUT_CLASSES, SETTING_CONFIG_DEFAULT, INITIAL_FILE_SIZE, - PROMPT_CONTENT_SEPARATOR, - PROMPT_TRIGGER_PREFIX + PROMPT_CONTENT_SEPARATOR } from '$lib/constants'; import { ContentPartType, @@ -47,14 +46,14 @@ } from '$lib/types'; import { buildMentionInsertion, + findCommandToken, findMentionToken, isIMEComposing, mentionLinkEndingAt, parseClipboardContent, - takeMentionDismissSnapshot, - type MentionDismissSnapshot, uuid } from '$lib/utils'; + import { useChatFormPickers } from '$lib/hooks/use-chat-form-pickers.svelte'; import { AudioRecorder, convertToWav, @@ -110,37 +109,52 @@ onValueChange }: Props = $props(); - // Component References let audioRecorder: AudioRecorder | undefined; let chatFormActionsRef: ChatFormActions | undefined = $state(undefined); let fileInputRef: ChatFormFileInputInvisible | undefined = $state(undefined); let pickersRef: { handleKeydown: (event: KeyboardEvent) => boolean } | undefined = $state(undefined); - let textareaRef: ChatFormTextarea | undefined = $state(undefined); + let inputRef: ChatFormTextarea | undefined = $state(undefined); // Audio Recording State let isRecording = $state(false); let recordingSupported = $state(false); - // Invisible anchor at the form's top edge so the mention popover floats above the box. + // Invisible anchor at the form's top edge so the mention/WD popovers + // float above the box. let mentionAnchor: HTMLDivElement | null = $state(null); - // Picker State - let isPromptPickerOpen = $state(false); - let promptSearchQuery = $state(''); - let isMentionPickerOpen = $state(false); - let mentionQuery = $state(''); - - // Last dismissed `@`-mention token; while intact the picker does not - // reopen, so an escaped `@` stays literal until edited. - let mentionDismissedSnapshot: MentionDismissSnapshot | null = null; - let cwd = $derived(activeConversation()?.cwd ?? pendingCwd()); - async function handleWorkingDirectoryChange(value: string | null) { - await conversationsStore.setCwd(value); + const pickers = useChatFormPickers({ + getValue: () => value, + setValue: (v) => { + value = v; + onValueChange?.(v); + }, + getCaretOffset: () => inputRef?.getCaretOffset(), + setCaretOffset: (offset) => inputRef?.setCaretOffset(offset), + focusInput: refocusInput, + getShowModelSelector: () => showModelSelector, + hasPrompts: () => mcpStore.hasPromptsCapability(conversationsStore.getAllMcpServerOverrides()), + hasBuiltinTools: () => toolsStore.builtinTools.length > 0, + getCwd: () => cwd, + getServerHome: () => toolsStore.serverHome ?? null, + openModelSelector: () => chatFormActionsRef?.openModelSelector(), + getPickersRef: () => pickersRef + }); + + async function handleWorkingDirectoryChange(newDir: string | null) { + // Committing a directory consumes the `/cwd` token; the chip's + // clear-X path has no token to consume. + const token = findCommandToken(value); + if (token && token.name === 'cwd') { + value = ''; + onValueChange?.(''); + } + await conversationsStore.setCwd(newDir); if (conversationsStore.activeConversation) { - await chatStore.recordCwdChange(value?.trim() || null); + await chatStore.recordCwdChange(newDir?.trim() || null); } } @@ -192,18 +206,12 @@ audioRecorder = new AudioRecorder(); }); - // Defer so the closing popover's focus scope tears down first - bits-ui - // yanks a synchronous focus() back into the still-mounted popover. - function refocusInput() { - queueMicrotask(() => textareaRef?.focus()); - } - export function focus() { - textareaRef?.focus(); + inputRef?.focus(); } export function resetTextareaHeight() { - textareaRef?.resetHeight(); + inputRef?.resetHeight(); } export function openModelSelector() { @@ -237,82 +245,28 @@ } } - function handleInput() { - const perChatOverrides = conversationsStore.getAllMcpServerOverrides(); - const hasServers = mcpStore.hasEnabledServers(perChatOverrides); - const cursor = textareaRef?.getCaretOffset() ?? value.length; - const mentionToken = findMentionToken(value, cursor); - - // A `@` mention takes precedence; typing one switches from any other open picker. - if (mentionToken && mentionToken.query.length > 0) { - isPromptPickerOpen = false; - promptSearchQuery = ''; - - const isDismissedSticky = - mentionDismissedSnapshot !== null && - mentionDismissedSnapshot.start === mentionToken.start && - mentionDismissedSnapshot.query === mentionToken.query; - - if (!isDismissedSticky) { - mentionDismissedSnapshot = null; - isMentionPickerOpen = true; - mentionQuery = mentionToken.query; - return; - } - - isMentionPickerOpen = false; - mentionQuery = ''; - return; - } - - isMentionPickerOpen = false; - mentionQuery = ''; - // Token gone or changed: reset the snapshot so a fresh `@` reopens. - if (mentionDismissedSnapshot !== null && !mentionToken) { - mentionDismissedSnapshot = null; - } - - if (value.startsWith(PROMPT_TRIGGER_PREFIX) && hasServers) { - isPromptPickerOpen = true; - promptSearchQuery = value.slice(1); - } else { - isPromptPickerOpen = false; - promptSearchQuery = ''; - } - } - function handleKeydown(event: KeyboardEvent) { - if (pickersRef?.handleKeydown(event)) { + // Pickers consume navigation/escape keys first; when consumed, skip + // the enter-to-submit logic below. + if (pickers.handleKeydown(event)) { return; } // Backspace at a mention link's end deletes the whole token at once. if (event.key === KeyboardKey.BACKSPACE && !event.ctrlKey && !event.metaKey && !event.altKey) { - const el = textareaRef?.getElement(); + const el = inputRef?.getElement(); if (el instanceof HTMLTextAreaElement && el.selectionStart === el.selectionEnd) { const link = mentionLinkEndingAt(value, el.selectionStart); if (link) { event.preventDefault(); value = value.slice(0, link.start) + value.slice(link.end); onValueChange?.(value); - queueMicrotask(() => textareaRef?.setCaretOffset(link.start)); + queueMicrotask(() => inputRef?.setCaretOffset(link.start)); return; } } } - if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) { - isPromptPickerOpen = false; - promptSearchQuery = ''; - return; - } - - if (event.key === KeyboardKey.ESCAPE && isMentionPickerOpen) { - isMentionPickerOpen = false; - mentionQuery = ''; - return; - } - if (event.key === KeyboardKey.ENTER && !event.shiftKey && !isIMEComposing(event)) { const isModifier = event.ctrlKey || event.metaKey; const sendOnEnter = currentConfig.sendOnEnter !== false; @@ -386,7 +340,7 @@ } setTimeout(() => { - textareaRef?.focus(); + inputRef?.focus(); }, 10); return; @@ -413,13 +367,7 @@ promptInfo: MCPPromptInfo, args?: Record ) { - // Only clear the value if the prompt was triggered by typing '/' - if (value.startsWith(PROMPT_TRIGGER_PREFIX)) { - value = ''; - onValueChange?.(''); - } - isPromptPickerOpen = false; - promptSearchQuery = ''; + pickers.closePromptPicker(); const promptName = promptInfo.title || promptInfo.name; const placeholder: ChatUploadedFile = { @@ -438,7 +386,7 @@ uploadedFiles = [...uploadedFiles, placeholder]; onUploadedFilesChange?.(uploadedFiles); - textareaRef?.focus(); + inputRef?.focus(); } function handlePromptLoadComplete(placeholderId: string, result: GetPromptResult) { @@ -480,26 +428,16 @@ onUploadedFilesChange?.(uploadedFiles); } - function handlePromptPickerClose() { - isPromptPickerOpen = false; - promptSearchQuery = ''; - textareaRef?.focus(); + // Deferred so the closing popover's focus scope tears down first - + // bits-ui yanks a synchronous focus() back into the still-mounted popover. + function refocusInput() { + queueMicrotask(() => inputRef?.focus()); } - function handleMentionPickerClose() { - if (isMentionPickerOpen) { - const cursor = textareaRef?.getCaretOffset() ?? value.length; - mentionDismissedSnapshot = takeMentionDismissSnapshot(value, cursor); - } - isMentionPickerOpen = false; - mentionQuery = ''; - refocusInput(); - } - - // Splice the `[name](file:///)` link in place of the `@` - // token, restoring the caret after the bindable value settles. + // Splice the mention link in place of the `@` token. Uses the + // live cursor, not a stale snapshot - the token may have been edited. function handleMentionSelect(entry: FileMentionEntry) { - const cursor = textareaRef?.getCaretOffset() ?? value.length; + const cursor = inputRef?.getCaretOffset() ?? value.length; const token = findMentionToken(value, cursor); if (!token) return; @@ -509,9 +447,10 @@ value = built.newValue; onValueChange?.(built.newValue); + // bind:value applies on the next microtask; restore the caret after. queueMicrotask(() => { - textareaRef?.focus(); - textareaRef?.setCaretOffset(built.caretOffset); + inputRef?.focus(); + inputRef?.setCaretOffset(built.caretOffset); }); } @@ -557,15 +496,20 @@ > textareaRef?.focus()} + scopePath={pickers.mentionScopePath} + onPromptPickerClose={pickers.handlePromptPickerClose} + onMentionPickerClose={pickers.handleMentionPickerClose} + onMentionOpened={() => inputRef?.focus()} onMentionSelect={handleMentionSelect} onPromptLoadStart={handlePromptLoadStart} onPromptLoadComplete={handlePromptLoadComplete} @@ -596,17 +540,17 @@
{ - handleInput(); + pickers.handleInput(); onValueChange?.(value); }} + onPaste={handlePaste} {disabled} {placeholder} /> @@ -636,7 +580,7 @@ onMicClick={handleMicClick} {onStop} onSystemPromptClick={() => onSystemPromptClick?.({ message: value, files: uploadedFiles })} - onMcpPromptClick={showMcpPromptButton ? () => (isPromptPickerOpen = true) : undefined} + onMcpPromptClick={showMcpPromptButton ? () => pickers.openPromptPicker() : undefined} onMcpResourcesClick={() => (isResourceDialogOpen = true)} />
@@ -647,8 +591,12 @@ {#if toolsStore.builtinTools.length > 0} {/if} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte new file mode 100644 index 0000000000..5feaaf35aa --- /dev/null +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte @@ -0,0 +1,135 @@ + + + + command.name} + scrollTrigger={nav.scrollTrigger} + > + {#snippet item(command, index, isSelected)} + {@const Icon = commandIcon[command.action]} + handleSelect(command)} + onmouseenter={() => { + if (!command.disabled) nav.setHover(index); + }} + > + +
+ /{command.name} + + {command.description} + +
+
+ {/snippet} +
+
diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte index f35d816de9..22ec531194 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte @@ -45,6 +45,9 @@ let promptArgs = $state>({}); let selectedIndex = $state(0); let internalSearchQuery = $state(''); + // Bumped on ArrowUp/ArrowDown only, so the list scrolls on keyboard + // nav but not on hover or result changes. + let scrollTrigger = $state(0); let promptError = $state(null); let selectedIndexBeforeArgumentForm = $state(null); @@ -295,6 +298,7 @@ event.preventDefault(); if (filteredPrompts.length > 0) { selectedIndex = (selectedIndex + 1) % filteredPrompts.length; + scrollTrigger++; } return true; @@ -304,6 +308,7 @@ event.preventDefault(); if (filteredPrompts.length > 0) { selectedIndex = selectedIndex === 0 ? filteredPrompts.length - 1 : selectedIndex - 1; + scrollTrigger++; } return true; @@ -400,6 +405,7 @@ searchPlaceholder="Search prompts..." emptyMessage="No MCP prompts available" itemKey={(prompt) => prompt.serverName + ':' + prompt.name} + {scrollTrigger} > {#snippet item(prompt, index, isSelected)} {@const server = serverSettingsMap.get(prompt.serverName)} diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte index 3fc29f1771..0d6cdcbfd6 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickers.svelte @@ -1,15 +1,26 @@ + {})} + onSelect={onCommandSelect ?? (() => {})} +/> + { if (textareaElement) { autoResizeTextarea(textareaElement); - textareaElement.focus(); + textareaElement.focus({ preventScroll: true }); } }); - // Expose the textarea element for external access export function getElement() { return textareaElement; } @@ -49,7 +48,7 @@ } } - // Plain-text caret offsets for the mention-splice flow. + // Plain-text caret offsets for the picker/paste/mention-splice flows. export function getCaretOffset(): number { if (!textareaElement) return 0; return textareaElement.selectionStart ?? textareaElement.value.length; diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte index f6ac9e0e86..f8069c93e5 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectory.svelte @@ -1,7 +1,5 @@ -
- - - + + + + + + event.preventDefault()} + onCloseAutoFocus={(event) => event.preventDefault()} + class="w-[var(--bits-popover-anchor-width)] max-w-none rounded-xl border-border/50 p-0 shadow-xl" + > +
+ - - event.preventDefault()} - > -
- 0 || searchError)} + nav.setHover(index)} /> + {/if} - {#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)} - (hoveredIndex = index)} - /> - {/if} + {#if pickerSupported} + + {/if} - {#if pickerSupported} -
+ + + Searching in: + + {abbreviateHome(searchScope, homeBase)} - - Browse - - {/if} - - {#if homeBase} - - - - Searching in: - - {abbreviateHome(searchScope, homeBase)} - - {/if} -
-
-
-
+ + {/if} + + + diff --git a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte index 4f8d0f7f7d..2882f557c8 100644 --- a/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte +++ b/tools/ui/src/lib/components/app/chat/ChatForm/ChatFormWorkingDirectoryChip.svelte @@ -1,6 +1,7 @@ diff --git a/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts b/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts new file mode 100644 index 0000000000..601ebc2b3f --- /dev/null +++ b/tools/ui/tests/client/settings-raw-text-migration.svelte.test.ts @@ -0,0 +1,65 @@ +// Guards the legacy render-key migration: `renderUserContentAsMarkdown` +// and `renderThinkingAsMarkdown` (opt-INTO markdown) fold into the single +// `renderContentAsRawText` setting, with any explicit raw-text preference +// winning when the legacy keys disagree. Legacy keys are removed from the +// persisted config so they do not stay orphaned in localStorage. + +import { beforeEach, describe, expect, it } from 'vitest'; +import { settingsStore, config } from '$lib/stores/settings.svelte'; +import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage'; + +function seedConfig(stored: Record) { + localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored)); + settingsStore.initialize(); +} + +function persisted(): Record { + return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}'); +} + +describe('renderContentAsRawText migration', () => { + beforeEach(() => { + localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY); + settingsStore.initialize(); + }); + + it('maps renderUserContentAsMarkdown=false to raw text', () => { + seedConfig({ renderUserContentAsMarkdown: false }); + expect(config().renderContentAsRawText).toBe(true); + }); + + it('maps renderUserContentAsMarkdown=true to markdown', () => { + seedConfig({ renderUserContentAsMarkdown: true }); + expect(config().renderContentAsRawText).toBe(false); + }); + + it('maps renderThinkingAsMarkdown=false to raw text', () => { + seedConfig({ renderThinkingAsMarkdown: false }); + expect(config().renderContentAsRawText).toBe(true); + }); + + it('lets any explicit raw-text preference win when the legacy keys disagree', () => { + seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false }); + expect(config().renderContentAsRawText).toBe(true); + }); + + it('honors the intermediate renderUserContentAsRawText key from the PR branch', () => { + seedConfig({ renderUserContentAsRawText: true }); + expect(config().renderContentAsRawText).toBe(true); + }); + + it('keeps an already-migrated value and cleans up the legacy keys', () => { + seedConfig({ renderContentAsRawText: false, renderUserContentAsMarkdown: false }); + expect(config().renderContentAsRawText).toBe(false); + + const stored = persisted(); + expect(stored.renderUserContentAsMarkdown).toBeUndefined(); + expect(stored.renderThinkingAsMarkdown).toBeUndefined(); + expect(stored.renderUserContentAsRawText).toBeUndefined(); + }); + + it('defaults to markdown when no legacy key exists', () => { + seedConfig({}); + expect(config().renderContentAsRawText).toBe(false); + }); +}); diff --git a/tools/ui/tests/unit/command-token.test.ts b/tools/ui/tests/unit/command-token.test.ts new file mode 100644 index 0000000000..f52097de5d --- /dev/null +++ b/tools/ui/tests/unit/command-token.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils'; + +describe('findCommandToken', () => { + it('returns null when the value does not start with a slash', () => { + expect(findCommandToken('hello /prompt')).toBeNull(); + expect(findCommandToken('')).toBeNull(); + expect(findCommandToken('prompt')).toBeNull(); + }); + + it('parses a bare slash', () => { + expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 }); + }); + + it('parses a command name with no args', () => { + expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 }); + }); + + it('parses a command name followed by a space', () => { + expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 }); + }); + + it('parses args after the command name', () => { + expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 }); + }); + + it('parses multi-word args', () => { + expect(findCommandToken('/prompt review code ')).toEqual({ + name: 'prompt', + args: ' review code ', + end: 22 + }); + }); + + it('treats the whole run as the name when there is no space', () => { + expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 }); + }); +}); + +describe('takeCommandDismissSnapshot', () => { + it('returns null when there is no command token', () => { + expect(takeCommandDismissSnapshot('hello')).toBeNull(); + }); + + it('captures the name and args', () => { + expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({ + name: 'prompt', + args: 'rev' + }); + }); +});