mirror of
https://github.com/ggml-org/llama.cpp.git
synced 2026-08-12 22:31:11 +04:00
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
This commit is contained in:
Vendored
+2
-4
@@ -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;
|
||||
|
||||
@@ -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 `@<query>` 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<string, string>
|
||||
) {
|
||||
// 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:///<abs path>)` link in place of the `@<query>`
|
||||
// token, restoring the caret after the bindable value settles.
|
||||
// Splice the mention link in place of the `@<query>` 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 @@
|
||||
>
|
||||
<ChatFormPickers
|
||||
bind:this={pickersRef}
|
||||
{isPromptPickerOpen}
|
||||
{promptSearchQuery}
|
||||
{isMentionPickerOpen}
|
||||
{mentionQuery}
|
||||
isCommandPickerOpen={pickers.isCommandPickerOpen}
|
||||
commandQuery={pickers.commandQuery}
|
||||
commands={pickers.availableCommands}
|
||||
onCommandPickerClose={pickers.handleCommandPickerClose}
|
||||
onCommandSelect={pickers.handleCommandSelect}
|
||||
isPromptPickerOpen={pickers.isPromptPickerOpen}
|
||||
promptSearchQuery={pickers.promptSearchQuery}
|
||||
isMentionPickerOpen={pickers.isMentionPickerOpen}
|
||||
mentionQuery={pickers.mentionQuery}
|
||||
{mentionAnchor}
|
||||
scopePath={cwd}
|
||||
onPromptPickerClose={handlePromptPickerClose}
|
||||
onMentionPickerClose={handleMentionPickerClose}
|
||||
onMentionOpened={() => textareaRef?.focus()}
|
||||
scopePath={pickers.mentionScopePath}
|
||||
onPromptPickerClose={pickers.handlePromptPickerClose}
|
||||
onMentionPickerClose={pickers.handleMentionPickerClose}
|
||||
onMentionOpened={() => inputRef?.focus()}
|
||||
onMentionSelect={handleMentionSelect}
|
||||
onPromptLoadStart={handlePromptLoadStart}
|
||||
onPromptLoadComplete={handlePromptLoadComplete}
|
||||
@@ -596,17 +540,17 @@
|
||||
|
||||
<div
|
||||
class="flex-column relative min-h-12 items-center rounded-4xl md:rounded-3xl py-2 pb-2.25 shadow-sm transition-all focus-within:shadow-md md:py-3!"
|
||||
onpaste={handlePaste}
|
||||
>
|
||||
<ChatFormTextarea
|
||||
class="px-5 py-1.5 md:pt-0"
|
||||
bind:this={textareaRef}
|
||||
bind:this={inputRef}
|
||||
bind:value
|
||||
onKeydown={handleKeydown}
|
||||
onInput={() => {
|
||||
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)}
|
||||
/>
|
||||
</div>
|
||||
@@ -647,8 +591,12 @@
|
||||
{#if toolsStore.builtinTools.length > 0}
|
||||
<ChatFormWorkingDirectory
|
||||
directory={cwd}
|
||||
isOpen={pickers.isWorkingDirectoryPickerOpen}
|
||||
bind:query={pickers.workingDirectoryQuery}
|
||||
customAnchor={mentionAnchor}
|
||||
onChange={handleWorkingDirectoryChange}
|
||||
onClose={refocusInput}
|
||||
onClose={pickers.handleWorkingDirectoryClose}
|
||||
onOpen={pickers.handleWorkingDirectoryOpen}
|
||||
{disabled}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen, Sparkles } from '@lucide/svelte';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
import {
|
||||
ChatFormPickerList,
|
||||
ChatFormPickerListItem,
|
||||
ChatFormPickerPopover
|
||||
} from '$lib/components/app/chat';
|
||||
|
||||
/**
|
||||
* Slash-command picker; `query` (typed after `/`) filters the commands.
|
||||
* The parent owns the "dismissed token, don't act until it changes"
|
||||
* snapshot, so this picker just renders and reports selection.
|
||||
*/
|
||||
interface Props {
|
||||
class?: string;
|
||||
isOpen: boolean;
|
||||
query: string;
|
||||
commands: ChatFormCommand[];
|
||||
onClose: () => void;
|
||||
onSelect: (command: ChatFormCommand) => void;
|
||||
}
|
||||
|
||||
let { class: className = '', isOpen, query, commands, onClose, onSelect }: Props = $props();
|
||||
|
||||
const commandIcon: Record<ChatFormCommandAction, typeof Sparkles> = {
|
||||
[ChatFormCommandAction.PROMPT]: Sparkles,
|
||||
[ChatFormCommandAction.CWD]: FolderOpen,
|
||||
[ChatFormCommandAction.MODEL]: MODEL_SELECTOR_ICON
|
||||
};
|
||||
|
||||
const trimmedQuery = $derived((query ?? '').trim().toLowerCase());
|
||||
|
||||
const filteredCommands = $derived(
|
||||
trimmedQuery
|
||||
? commands.filter(
|
||||
(c) =>
|
||||
c.name.toLowerCase().includes(trimmedQuery) ||
|
||||
c.description.toLowerCase().includes(trimmedQuery) ||
|
||||
(c.keywords ?? []).some((k) => k.toLowerCase().includes(trimmedQuery))
|
||||
)
|
||||
: commands
|
||||
);
|
||||
|
||||
function firstEnabledIndex(): number {
|
||||
return filteredCommands.findIndex((c) => !c.disabled);
|
||||
}
|
||||
|
||||
function stepEnabled(from: number, dir: number): number {
|
||||
const n = filteredCommands.length;
|
||||
if (n === 0) return -1;
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const idx = (from + dir * i + n) % n;
|
||||
if (!filteredCommands[idx].disabled) return idx;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => filteredCommands.length,
|
||||
step: (from, dir) => (from < 0 ? firstEnabledIndex() : stepEnabled(from, dir)),
|
||||
onClose: () => onClose(),
|
||||
onSelect: (index) => handleSelect(filteredCommands[index])
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isOpen) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (nav.hoveredIndex < 0 || nav.hoveredIndex >= filteredCommands.length) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
return;
|
||||
}
|
||||
if (filteredCommands[nav.hoveredIndex].disabled) {
|
||||
nav.reset(firstEnabledIndex());
|
||||
}
|
||||
});
|
||||
|
||||
function handleSelect(command: ChatFormCommand) {
|
||||
if (command.disabled) return;
|
||||
onSelect(command);
|
||||
onClose();
|
||||
}
|
||||
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
return nav.handleKeydown(event);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormPickerPopover
|
||||
bind:isOpen
|
||||
class={className}
|
||||
srLabel="Open command picker"
|
||||
{onClose}
|
||||
onKeydown={handleKeydown}
|
||||
>
|
||||
<ChatFormPickerList
|
||||
items={filteredCommands}
|
||||
isLoading={false}
|
||||
selectedIndex={nav.hoveredIndex}
|
||||
showSearchInput={false}
|
||||
searchQuery={query ?? ''}
|
||||
emptyMessage="No matching command"
|
||||
itemKey={(command) => command.name}
|
||||
scrollTrigger={nav.scrollTrigger}
|
||||
>
|
||||
{#snippet item(command, index, isSelected)}
|
||||
{@const Icon = commandIcon[command.action]}
|
||||
<ChatFormPickerListItem
|
||||
dataIndex={index}
|
||||
{isSelected}
|
||||
disabled={command.disabled}
|
||||
onclick={() => handleSelect(command)}
|
||||
onmouseenter={() => {
|
||||
if (!command.disabled) nav.setHover(index);
|
||||
}}
|
||||
>
|
||||
<Icon class="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="font-mono text-sm font-medium">/{command.name}</span>
|
||||
<span class="min-w-0 flex-1 truncate text-left text-xs text-muted-foreground">
|
||||
{command.description}
|
||||
</span>
|
||||
</div>
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</ChatFormPickerPopover>
|
||||
+6
@@ -45,6 +45,9 @@
|
||||
let promptArgs = $state<Record<string, string>>({});
|
||||
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<string | null>(null);
|
||||
let selectedIndexBeforeArgumentForm = $state<number | null>(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)}
|
||||
|
||||
+32
-5
@@ -1,15 +1,26 @@
|
||||
<script lang="ts">
|
||||
import ChatFormCommandPicker from './ChatFormCommandPicker.svelte';
|
||||
import ChatFormMentionPicker from './ChatFormMentionPicker.svelte';
|
||||
import ChatFormPickerMcpPrompts from './ChatFormPickerMcpPrompts/ChatFormPickerMcpPrompts.svelte';
|
||||
import type { FileMentionEntry, GetPromptResult, MCPPromptInfo } from '$lib/types';
|
||||
import type {
|
||||
ChatFormCommand,
|
||||
FileMentionEntry,
|
||||
GetPromptResult,
|
||||
MCPPromptInfo
|
||||
} from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
isCommandPickerOpen?: boolean;
|
||||
commandQuery?: string;
|
||||
commands?: ChatFormCommand[];
|
||||
isPromptPickerOpen?: boolean;
|
||||
promptSearchQuery?: string;
|
||||
isMentionPickerOpen?: boolean;
|
||||
mentionQuery?: string;
|
||||
mentionAnchor?: HTMLElement | null;
|
||||
scopePath?: string | null;
|
||||
onCommandPickerClose?: () => void;
|
||||
onCommandSelect?: (command: ChatFormCommand) => void;
|
||||
onPromptPickerClose?: () => void;
|
||||
onMentionPickerClose?: () => void;
|
||||
onMentionOpened?: () => void;
|
||||
@@ -24,6 +35,11 @@
|
||||
}
|
||||
|
||||
let {
|
||||
isCommandPickerOpen,
|
||||
commandQuery,
|
||||
commands = [],
|
||||
onCommandPickerClose,
|
||||
onCommandSelect,
|
||||
isPromptPickerOpen,
|
||||
promptSearchQuery,
|
||||
isMentionPickerOpen,
|
||||
@@ -39,14 +55,16 @@
|
||||
onPromptLoadError
|
||||
}: Props = $props();
|
||||
|
||||
let commandPickerRef: ChatFormCommandPicker | undefined = $state(undefined);
|
||||
let promptPickerRef: ChatFormPickerMcpPrompts | undefined = $state(undefined);
|
||||
let mentionPickerRef: ChatFormMentionPicker | undefined = $state(undefined);
|
||||
|
||||
/**
|
||||
* Delegates keyboard events to the active picker child.
|
||||
* Returns true if the event was handled.
|
||||
*/
|
||||
/** Delegate keyboard events to the active picker child; true if handled. */
|
||||
export function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (isCommandPickerOpen && commandPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isPromptPickerOpen && promptPickerRef?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
@@ -59,6 +77,15 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormCommandPicker
|
||||
bind:this={commandPickerRef}
|
||||
isOpen={isCommandPickerOpen ?? false}
|
||||
query={commandQuery ?? ''}
|
||||
{commands}
|
||||
onClose={onCommandPickerClose ?? (() => {})}
|
||||
onSelect={onCommandSelect ?? (() => {})}
|
||||
/>
|
||||
|
||||
<ChatFormPickerMcpPrompts
|
||||
bind:this={promptPickerRef}
|
||||
isOpen={isPromptPickerOpen}
|
||||
|
||||
@@ -28,11 +28,10 @@
|
||||
onMount(() => {
|
||||
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;
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { FolderOpen } from '@lucide/svelte';
|
||||
import { untrack } from 'svelte';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool, GlobSearchType, KeyboardKey } from '$lib/enums';
|
||||
@@ -10,23 +8,22 @@
|
||||
buildCaseInsensitiveGlob,
|
||||
joinPath,
|
||||
lastPathSegment,
|
||||
rankEntries,
|
||||
splitPathQuery,
|
||||
runGlobSearchWithChildren,
|
||||
type GlobEntry
|
||||
} from '$lib/utils';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import SearchInput from '$lib/components/app/forms/SearchInput.svelte';
|
||||
import { useDebouncedSearch } from '$lib/hooks/use-debounced-search.svelte';
|
||||
import { usePickerNavigation } from '$lib/hooks/use-picker-navigation.svelte';
|
||||
import { useScrollActiveRow } from '$lib/hooks/use-scroll-active-row.svelte';
|
||||
import ChatFormWorkingDirectoryChip from './ChatFormWorkingDirectoryChip.svelte';
|
||||
import ChatFormWorkingDirectoryResultsList from './ChatFormWorkingDirectoryResultsList.svelte';
|
||||
import {
|
||||
DEFAULT_MOBILE_BREAKPOINT,
|
||||
GLOB_WILDCARD,
|
||||
HOME_TILDE,
|
||||
MAX_RESULTS_SHOWN,
|
||||
NATIVE_LIMIT,
|
||||
NATIVE_MAX_DEPTH,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
SEARCH_DEBOUNCE_MS,
|
||||
SEARCH_LIMIT,
|
||||
SEARCH_MAX_DEPTH
|
||||
@@ -39,228 +36,147 @@
|
||||
class?: string;
|
||||
disabled?: boolean;
|
||||
directory?: string | null;
|
||||
/** Controlled open state; the host owns it so the chip click and the
|
||||
* `/cwd` slash command open the picker through the same path. */
|
||||
isOpen: boolean;
|
||||
/** Two-way bound query, kept in sync with the text after `/cwd `. */
|
||||
query: string;
|
||||
/** Anchor at the form's top edge so the popover floats above the box. */
|
||||
customAnchor?: HTMLElement | null;
|
||||
onChange?: (directory: string | null) => void;
|
||||
/**
|
||||
* Lets the host refocus the chat input so typing can resume without
|
||||
* an extra click after the popover closes.
|
||||
*/
|
||||
/** Lets the host refocus the chat input after the popover closes. */
|
||||
onClose?: () => void;
|
||||
/** Fired when the chip is clicked so the host can open the picker. */
|
||||
onOpen?: () => void;
|
||||
}
|
||||
|
||||
let {
|
||||
class: className = '',
|
||||
disabled = false,
|
||||
directory = $bindable(null),
|
||||
directory = null,
|
||||
isOpen,
|
||||
query = $bindable(''),
|
||||
customAnchor = null,
|
||||
onChange,
|
||||
onClose
|
||||
onClose,
|
||||
onOpen
|
||||
}: Props = $props();
|
||||
|
||||
// File System Access API is opt-in: when available (Chrome / Edge / Opera) the popover
|
||||
// exposes a "Browse" button that opens the native folder picker. When unavailable the
|
||||
// popover still works via the text input - no alerts, no upload semantics.
|
||||
// File System Access API is opt-in (Chrome / Edge / Opera): the popover
|
||||
// exposes a "Browse" button only when available.
|
||||
const pickerSupported =
|
||||
typeof window !== 'undefined' && typeof window.showDirectoryPicker === 'function';
|
||||
|
||||
// Popover open state; the element handles outside-click and Escape.
|
||||
let isOpen = $state(false);
|
||||
let inputValue = $state('');
|
||||
let searchInputRef: HTMLInputElement | null = $state(null);
|
||||
|
||||
let queryResults = $state<string[]>([]);
|
||||
let isSearching = $state(false);
|
||||
let searchError = $state<string | null>(null);
|
||||
let hoveredIndex = $state(-1);
|
||||
// Bumped only by ArrowUp/ArrowDown handlers; the list scrolls the
|
||||
// highlighted row into view only via this trigger, never on hover.
|
||||
let scrollTrigger = $state(0);
|
||||
let listContainer = $state<HTMLDivElement | null>(null);
|
||||
|
||||
// Absolute home directory on the server, resolved once per session by
|
||||
// the tools store. Anchors both the search scope and the chip's `~`
|
||||
// abbreviation.
|
||||
const nav = usePickerNavigation({
|
||||
isOpen: () => isOpen,
|
||||
count: () => queryResults.length,
|
||||
onClose: closePicker,
|
||||
onSelect: (index) => commit(queryResults[index])
|
||||
});
|
||||
|
||||
let homeBase = $derived(toolsStore.serverHome);
|
||||
|
||||
// AbortController + sequence counter to discard stale responses when the user
|
||||
// keeps typing; a newer call aborts the previous one. The sequence counter
|
||||
// also covers the gap between abort and the catch handler.
|
||||
let searchController: AbortController | null = null;
|
||||
let searchSeq = 0;
|
||||
|
||||
// Cache of the last file_glob_search result per (parent, include, max_depth),
|
||||
// so repeated queries in the same directory don't re-walk the tree. Entering
|
||||
// a directory hits it every time: the children listed for an exactly typed
|
||||
// segment are what the next keystroke, the trailing slash, asks for again.
|
||||
const SEARCH_CACHE_TTL_MS = 2000;
|
||||
const searchCache = new SvelteMap<string, { results: GlobEntry[]; base: string; at: number }>();
|
||||
|
||||
const runSearch = debounce((query: string) => {
|
||||
void doSearch(query);
|
||||
}, SEARCH_DEBOUNCE_MS);
|
||||
|
||||
// Resolve home eagerly on mount so the chip can abbreviate before the
|
||||
// user opens the picker. resolveServerHome() is cached, so repeat calls
|
||||
// (e.g. from handleOpenChange) are no-ops.
|
||||
// Resolve home eagerly so the chip can abbreviate before the picker opens.
|
||||
$effect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
void toolsStore.resolveServerHome();
|
||||
});
|
||||
|
||||
// Auto-focus the search input when the popover opens.
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements, so we
|
||||
// use a microtask (0ms setTimeout) after the effect flushes.
|
||||
// HTML `autofocus` is unreliable on dynamically shown elements.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
setTimeout(() => searchInputRef?.focus(), FOCUS_DELAY_MS);
|
||||
});
|
||||
|
||||
let lastScrollTrigger: number | null = null;
|
||||
|
||||
// hoveredIndex/queryResults are untracked so hover and result replacement
|
||||
// never re-fire the scroll; keyboard nav is the only path that bumps the trigger
|
||||
$effect(() => {
|
||||
if (scrollTrigger === lastScrollTrigger) return;
|
||||
lastScrollTrigger = scrollTrigger;
|
||||
untrack(() => {
|
||||
if (!listContainer) return;
|
||||
if (hoveredIndex < 0 || hoveredIndex >= queryResults.length) return;
|
||||
const selectedElement = listContainer.querySelector(
|
||||
`[data-result-index="${hoveredIndex}"]`
|
||||
) as HTMLElement | null;
|
||||
selectedElement?.scrollIntoView({ block: 'nearest', inline: 'nearest' });
|
||||
});
|
||||
if (!isOpen) return;
|
||||
const q = query.trim();
|
||||
nav.reset(-1);
|
||||
if (q) {
|
||||
search.run(q);
|
||||
} else {
|
||||
search.cancel();
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
}
|
||||
});
|
||||
|
||||
function cancelSearch() {
|
||||
searchController?.abort();
|
||||
searchSeq++;
|
||||
isSearching = false;
|
||||
}
|
||||
useScrollActiveRow({
|
||||
getTrigger: () => nav.scrollTrigger,
|
||||
getContainer: () => listContainer,
|
||||
getIndex: () => nav.hoveredIndex,
|
||||
getCount: () => queryResults.length,
|
||||
dataIndex: 'result'
|
||||
});
|
||||
|
||||
// Effective directory the current search runs against (shown in the
|
||||
// footer); updated by doSearch, including when an exactly-typed
|
||||
// directory is "entered".
|
||||
let searchScope = $state(HOME_TILDE);
|
||||
|
||||
// Runs a directory listing through the cache, so a repeated query in the
|
||||
// same directory does not re-walk the tree on the server.
|
||||
async function searchDirs(
|
||||
path: string,
|
||||
include: string,
|
||||
maxDepth: number,
|
||||
signal: AbortSignal
|
||||
): Promise<{ base: string; entries: GlobEntry[]; error?: string }> {
|
||||
const key = `${path}\u0000${include}\u0000${maxDepth}`;
|
||||
const cached = searchCache.get(key);
|
||||
if (cached && Date.now() - cached.at < SEARCH_CACHE_TTL_MS) {
|
||||
return { base: cached.base, entries: cached.results };
|
||||
}
|
||||
const res = await ToolsService.executeToolRaw(
|
||||
BuiltInTool.FILE_GLOB_SEARCH,
|
||||
{ path, type: GlobSearchType.DIR, include, max_depth: maxDepth, limit: SEARCH_LIMIT },
|
||||
signal
|
||||
);
|
||||
if (typeof res.error === 'string') return { base: '', entries: [], error: res.error };
|
||||
const base = typeof res.base === 'string' ? res.base : '';
|
||||
const entries = Array.isArray(res.entries) ? (res.entries as GlobEntry[]) : [];
|
||||
const now = Date.now();
|
||||
for (const [k, v] of searchCache) {
|
||||
if (now - v.at >= SEARCH_CACHE_TTL_MS) searchCache.delete(k);
|
||||
}
|
||||
searchCache.set(key, { results: entries, base, at: now });
|
||||
return { base, entries };
|
||||
}
|
||||
|
||||
async function doSearch(query: string) {
|
||||
const trimmed = query.trim();
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
isSearching = false;
|
||||
hoveredIndex = -1;
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
return;
|
||||
}
|
||||
|
||||
cancelSearch();
|
||||
const controller = new AbortController();
|
||||
searchController = controller;
|
||||
const mySeq = ++searchSeq;
|
||||
|
||||
const pathQuery = splitPathQuery(trimmed);
|
||||
|
||||
isSearching = true;
|
||||
try {
|
||||
// A generous limit is requested because ranking happens
|
||||
// client-side; only the top 20 are shown.
|
||||
const searchPath = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
const include = pathQuery
|
||||
? pathQuery.last
|
||||
? buildCaseInsensitiveGlob(pathQuery.last)
|
||||
: GLOB_WILDCARD
|
||||
: buildCaseInsensitiveGlob(trimmed);
|
||||
const maxDepth = pathQuery ? PATH_NAV_MAX_DEPTH : SEARCH_MAX_DEPTH;
|
||||
const res = await searchDirs(searchPath, include, maxDepth, controller.signal);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (res.error) {
|
||||
// An exactly-typed directory is "entered": the shared search lists its
|
||||
// children too, so path navigation does not require a trailing slash.
|
||||
const search = useDebouncedSearch({
|
||||
debounceMs: SEARCH_DEBOUNCE_MS,
|
||||
canRun: () => isOpen,
|
||||
getQuery: () => query.trim(),
|
||||
run: async (q, signal, isCurrent) => {
|
||||
const trimmed = q.trim();
|
||||
if (!trimmed) {
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
searchError = res.error;
|
||||
searchError = null;
|
||||
nav.reset(-1);
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
return;
|
||||
}
|
||||
const { base, entries } = res;
|
||||
const ranked = rankEntries(entries, pathQuery?.last ?? trimmed);
|
||||
let results = ranked.map((e) => joinPath(base, e.path));
|
||||
searchScope = pathQuery ? pathQuery.parent : (homeBase ?? HOME_TILDE);
|
||||
|
||||
// An exactly-typed directory is "entered": list its children too,
|
||||
// so path navigation doesn't require a trailing slash.
|
||||
const last = pathQuery?.last;
|
||||
const exact = last
|
||||
? ranked.find((e) => lastPathSegment(e.path).toLowerCase() === last.toLowerCase())
|
||||
: undefined;
|
||||
if (exact) {
|
||||
const exactDir = joinPath(base, exact.path);
|
||||
const childRes = await searchDirs(
|
||||
exactDir,
|
||||
GLOB_WILDCARD,
|
||||
PATH_NAV_MAX_DEPTH,
|
||||
controller.signal
|
||||
try {
|
||||
// Generous limit: ranking is client-side, only the top
|
||||
// MAX_RESULTS_SHOWN are shown.
|
||||
const res = await runGlobSearchWithChildren(
|
||||
trimmed,
|
||||
homeBase ?? HOME_TILDE,
|
||||
SEARCH_MAX_DEPTH,
|
||||
SEARCH_LIMIT,
|
||||
signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
if (mySeq !== searchSeq) return;
|
||||
if (!childRes.error) {
|
||||
const children = childRes.entries
|
||||
.map((e) => joinPath(childRes.base, e.path))
|
||||
.sort((a, b) => a.localeCompare(b));
|
||||
results = [...results, ...children];
|
||||
searchScope = exactDir;
|
||||
if (!isCurrent()) return;
|
||||
if (res.error) {
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = res.error;
|
||||
return;
|
||||
}
|
||||
|
||||
searchScope = res.exactDir ?? res.args.path;
|
||||
queryResults = res.entries.map((e) => e.path).slice(0, MAX_RESULTS_SHOWN);
|
||||
if (queryResults.length > 0) {
|
||||
nav.reset(0);
|
||||
nav.bumpScroll(); // scroll the list back to the top (first item is hovered)
|
||||
} else {
|
||||
nav.reset(-1);
|
||||
}
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (!isCurrent() || signal.aborted) return;
|
||||
queryResults = [];
|
||||
nav.reset(-1);
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
queryResults = results.slice(0, MAX_RESULTS_SHOWN);
|
||||
hoveredIndex = queryResults.length > 0 ? 0 : -1;
|
||||
// new results: scroll the list back to the top (first item is hovered)
|
||||
if (hoveredIndex === 0) scrollTrigger++;
|
||||
searchError = null;
|
||||
} catch (err) {
|
||||
if (mySeq !== searchSeq) return;
|
||||
queryResults = [];
|
||||
hoveredIndex = -1;
|
||||
if (controller.signal.aborted) return;
|
||||
searchError = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
if (mySeq === searchSeq) isSearching = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Single funnel for every local close so the host refocus fires
|
||||
// regardless of which commit/dismiss path ended the interaction.
|
||||
});
|
||||
// Single funnel for every local close so the host refocus always fires.
|
||||
function closePicker() {
|
||||
isOpen = false;
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function commit(path: string) {
|
||||
directory = path;
|
||||
onChange?.(path);
|
||||
closePicker();
|
||||
}
|
||||
@@ -268,15 +184,12 @@
|
||||
function setDirectory(value: string) {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return;
|
||||
directory = trimmed;
|
||||
onChange?.(trimmed);
|
||||
}
|
||||
|
||||
// Resolve a folder name picked via the browser-native picker (which exposes
|
||||
// only the leaf name) to a server-side absolute path. Returns null when the
|
||||
// server cannot locate a matching directory, so the caller can fail visibly
|
||||
// instead of committing a bare leaf name that would resolve against the
|
||||
// server process working directory.
|
||||
// Resolve a browser-picked folder name (which exposes only the leaf name)
|
||||
// to a server-side absolute path; null when the server cannot locate it,
|
||||
// so the caller fails visibly instead of committing a bare leaf name.
|
||||
async function resolveNativeName(name: string): Promise<string | null> {
|
||||
try {
|
||||
const res = await ToolsService.executeToolRaw(BuiltInTool.FILE_GLOB_SEARCH, {
|
||||
@@ -318,7 +231,7 @@
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
const value = inputValue.trim();
|
||||
const value = query.trim();
|
||||
if (!value) {
|
||||
closePicker();
|
||||
return;
|
||||
@@ -330,47 +243,33 @@
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
// Commit the highlighted result, falling back to the raw input
|
||||
// only when the query returned no matches.
|
||||
if (hoveredIndex >= 0 && queryResults[hoveredIndex]) {
|
||||
commit(queryResults[hoveredIndex]);
|
||||
if (nav.hoveredIndex >= 0 && queryResults[nav.hoveredIndex]) {
|
||||
commit(queryResults[nav.hoveredIndex]);
|
||||
} else if (queryResults.length === 0) {
|
||||
handleSubmit();
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = (hoveredIndex + 1) % queryResults.length;
|
||||
scrollTrigger++;
|
||||
nav.move(1);
|
||||
}
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
if (queryResults.length > 0) {
|
||||
event.preventDefault();
|
||||
hoveredIndex = hoveredIndex <= 0 ? queryResults.length - 1 : hoveredIndex - 1;
|
||||
scrollTrigger++;
|
||||
nav.move(-1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function handleInputInput(value: string) {
|
||||
hoveredIndex = -1;
|
||||
if (value.trim().length > 0) {
|
||||
runSearch(value);
|
||||
}
|
||||
}
|
||||
|
||||
function clearDirectory(event?: MouseEvent) {
|
||||
// Stop the click from bubbling into the popover trigger and re-opening
|
||||
// Stop the click from bubbling into the chip button and re-opening
|
||||
// the picker on top of the now-cleared state.
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
directory = null;
|
||||
onChange?.(null);
|
||||
closePicker();
|
||||
}
|
||||
|
||||
// The chip is always visible; the X clears the directory (no-op when
|
||||
// already empty).
|
||||
function handleDismiss(event?: MouseEvent) {
|
||||
event?.stopPropagation();
|
||||
event?.preventDefault();
|
||||
@@ -380,105 +279,104 @@
|
||||
}
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
isOpen = open;
|
||||
if (open) {
|
||||
// Seed the search field with the current path so the user can refine it
|
||||
// (or hit Enter to confirm / clear via the X icon).
|
||||
inputValue = directory ?? '';
|
||||
hoveredIndex = -1;
|
||||
queryResults = [];
|
||||
searchError = null;
|
||||
void toolsStore.resolveServerHome();
|
||||
searchScope = homeBase ?? HOME_TILDE;
|
||||
if (inputValue.trim()) runSearch(inputValue);
|
||||
} else {
|
||||
cancelSearch();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click,
|
||||
// trigger toggle) - the only path that bypasses closePicker().
|
||||
search.cancel();
|
||||
// bits-ui-initiated close (Escape on the content, outside-click) -
|
||||
// the only path that bypasses closePicker().
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
|
||||
// Tooltips only on wider viewports - hover surfaces get in the way on
|
||||
// touch / narrow layouts. Mirrors the gate used in ActionIcon.
|
||||
let innerWidth = $state(0);
|
||||
const showTooltip = $derived(innerWidth > DEFAULT_MOBILE_BREAKPOINT);
|
||||
</script>
|
||||
|
||||
<div
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
'justify-self-start flex min-w-0 w-auto items-center gap-1 mt-1.5 py-1 px-2 backdrop-blur-2xl rounded-md',
|
||||
className,
|
||||
isOpen && 'w-full'
|
||||
className
|
||||
]}
|
||||
onclick={onOpen}
|
||||
{disabled}
|
||||
>
|
||||
<Popover.Root bind:open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger {disabled} class="flex justify-start">
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
<ChatFormWorkingDirectoryChip
|
||||
{directory}
|
||||
{homeBase}
|
||||
{disabled}
|
||||
{showTooltip}
|
||||
onClear={handleDismiss}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<Popover.Root open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<Popover.Trigger
|
||||
class="pointer-events-none absolute inset-0 opacity-0"
|
||||
tabindex={-1}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="sr-only">Open working directory picker</span>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={12}
|
||||
{customAnchor}
|
||||
preventScroll={false}
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => 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"
|
||||
>
|
||||
<div class="p-2 min-h-22 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={query}
|
||||
placeholder="Choose working directory"
|
||||
onClose={closePicker}
|
||||
class="w-full"
|
||||
/>
|
||||
</Popover.Trigger>
|
||||
|
||||
<Popover.Content
|
||||
side="top"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
class="md:max-w-3xl w-[calc(100vw-1rem)] rounded-xl border-border/50 p-0 shadow-xl md:-translate-2!"
|
||||
onkeydown={handleKeydown}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<div class="p-2 min-h-28 flex flex-col justify-between">
|
||||
<SearchInput
|
||||
bind:ref={searchInputRef}
|
||||
bind:value={inputValue}
|
||||
placeholder="Choose working directory"
|
||||
onInput={handleInputInput}
|
||||
onClose={closePicker}
|
||||
class="w-full"
|
||||
{#if query.trim() && (search.isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
hoveredIndex={nav.hoveredIndex}
|
||||
isSearching={search.isSearching}
|
||||
error={searchError}
|
||||
rawQuery={query}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => nav.setHover(index)}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if inputValue.trim() && (isSearching || queryResults.length > 0 || searchError)}
|
||||
<ChatFormWorkingDirectoryResultsList
|
||||
results={queryResults}
|
||||
{hoveredIndex}
|
||||
{isSearching}
|
||||
error={searchError}
|
||||
rawQuery={inputValue}
|
||||
bind:container={listContainer}
|
||||
onCommit={commit}
|
||||
onHover={(index) => (hoveredIndex = index)}
|
||||
/>
|
||||
{/if}
|
||||
{#if pickerSupported}
|
||||
<button
|
||||
type="button"
|
||||
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={browseNative}
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if pickerSupported}
|
||||
<button
|
||||
type="button"
|
||||
class="-mt-1 flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none hover:bg-accent hover:text-accent-foreground"
|
||||
onclick={browseNative}
|
||||
{#if homeBase}
|
||||
<div class="-mx-2 my-2 h-px bg-border/20" aria-hidden="true"></div>
|
||||
|
||||
<span class="px-2 py-1.5 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
<FolderOpen class="size-4 shrink-0 text-muted-foreground" />
|
||||
<span>Browse</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if homeBase}
|
||||
<div class="-mx-2 my-1 h-px bg-border/20" aria-hidden="true"></div>
|
||||
|
||||
<span class="px-2 py-2 font-mono text-[10px]">
|
||||
Searching in:
|
||||
|
||||
<span class="truncate text-muted-foreground/70" title={searchScope}
|
||||
>{abbreviateHome(searchScope, homeBase)}</span
|
||||
>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</div>
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
|
||||
<svelte:window bind:innerWidth />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { Folder, X } from '@lucide/svelte';
|
||||
import { abbreviateWorkingDir } from '$lib/utils';
|
||||
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { ActionIcon } from '$lib/components/app/actions';
|
||||
|
||||
@@ -21,7 +22,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
const displayLabel = $derived(
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : 'Select working directory'
|
||||
directory ? abbreviateWorkingDir(directory, homeBase) : SET_WORKING_DIRECTORY_LABEL
|
||||
);
|
||||
// Full path surface: hover the abbreviated label to recall the exact directory.
|
||||
const displayLabelTitle = $derived(directory ?? '');
|
||||
|
||||
+4
-4
@@ -183,8 +183,8 @@
|
||||
<ChatMessageAssistantProcessingInfo {modelLoadingText} {processingState} position="bottom" />
|
||||
{/if}
|
||||
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
{#if displayedModel}
|
||||
{#if displayedModel}
|
||||
<div class="info my-6 grid gap-4 tabular-nums">
|
||||
<div class="inline-flex flex-wrap items-start gap-2 text-xs text-muted-foreground">
|
||||
<ChatMessageAssistantModel
|
||||
{displayedModel}
|
||||
@@ -200,8 +200,8 @@
|
||||
showMessageStats={currentConfig.showMessageStats}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if message.timestamp && !editCtx.isEditing}
|
||||
<ChatMessageActionIcons
|
||||
|
||||
+1
-1
@@ -164,7 +164,7 @@
|
||||
? `max-height: ${MAX_HEIGHT}px;`
|
||||
: 'max-height: none;'}
|
||||
>
|
||||
{#if currentConfig.renderUserContentAsMarkdown}
|
||||
{#if !currentConfig.renderContentAsRawText}
|
||||
<div bind:this={messageElement} class={isExpanded ? 'cursor-text' : ''}>
|
||||
<MarkdownContent class="markdown-system-content" content={message.content} />
|
||||
</div>
|
||||
|
||||
+3
-2
@@ -98,9 +98,10 @@
|
||||
showSpinner || (toolUi?.icon ?? null) || !mcpServerFavicon ? null : mcpServerFavicon
|
||||
);
|
||||
|
||||
// No subtitle while the call is in flight - the spinner already
|
||||
// signals activity; only terminal states get a pill.
|
||||
function subtitleFor(errorMessage?: string): string | undefined {
|
||||
if (extraLiveStreaming) return 'streaming...';
|
||||
if (showSpinner) return 'executing...';
|
||||
if (showSpinner) return undefined;
|
||||
if (errorMessage) return 'failed';
|
||||
if (isStreamingCall && !isStreaming) return 'incomplete';
|
||||
return undefined;
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@
|
||||
data-multiline={isMultiline ? '' : undefined}
|
||||
style="{maxHeightStyle} overflow-wrap: anywhere; word-break: break-word;"
|
||||
>
|
||||
{#if renderMarkdown && currentConfig.renderUserContentAsMarkdown}
|
||||
{#if renderMarkdown && !currentConfig.renderContentAsRawText}
|
||||
<div bind:this={messageElement}>
|
||||
<MarkdownContent class="markdown-user-content" {content} />
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
let expandedStates: Record<number, boolean> = $state({});
|
||||
|
||||
const renderThinkingAsMarkdown = $derived(config().renderThinkingAsMarkdown as boolean);
|
||||
const showThoughtInProgress = $derived(Boolean(config().showThoughtInProgress));
|
||||
const alwaysShowToolCallContent = $derived(Boolean(config().alwaysShowToolCallContent));
|
||||
const showMessageStats = $derived(Boolean(config().showMessageStats));
|
||||
@@ -186,7 +185,6 @@
|
||||
{section}
|
||||
open={isExpanded(index, section)}
|
||||
{isStreaming}
|
||||
{renderThinkingAsMarkdown}
|
||||
{hasReasoningError}
|
||||
attachments={message?.extra}
|
||||
onToggle={() => toggleExpanded(index, section)}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { CollapsibleContentBlock, MarkdownContent } from '$lib/components/app';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { REASONING_SCROLL_AT_BOTTOM_THRESHOLD_PX } from '$lib/constants/auto-scroll';
|
||||
import { config } from '$lib/stores/settings.svelte';
|
||||
import type { DatabaseMessageExtra } from '$lib/types';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
|
||||
@@ -10,7 +11,6 @@
|
||||
section: AgenticSection;
|
||||
open: boolean;
|
||||
isStreaming: boolean;
|
||||
renderThinkingAsMarkdown: boolean;
|
||||
hasReasoningError?: boolean;
|
||||
attachments?: DatabaseMessageExtra[];
|
||||
onToggle?: () => void;
|
||||
@@ -20,12 +20,13 @@
|
||||
section,
|
||||
open,
|
||||
isStreaming,
|
||||
renderThinkingAsMarkdown,
|
||||
hasReasoningError = false,
|
||||
attachments,
|
||||
onToggle
|
||||
}: Props = $props();
|
||||
|
||||
const currentConfig = config();
|
||||
|
||||
const REASONING_HEADER = 'Reasoning';
|
||||
const REASONING_HEADER_PENDING = 'Reasoning...';
|
||||
const REASONING_SUBTITLE_ERROR = 'Error';
|
||||
@@ -128,7 +129,7 @@
|
||||
class:is-streaming={isPending}
|
||||
onscroll={handleScrollEvent}
|
||||
>
|
||||
{#if renderThinkingAsMarkdown}
|
||||
{#if !currentConfig.renderContentAsRawText}
|
||||
<MarkdownContent content={section.content} class="text-muted-foreground" {attachments} />
|
||||
{:else}
|
||||
<div
|
||||
|
||||
@@ -266,9 +266,9 @@ export { default as ChatFormFileInputInvisible } from './ChatForm/ChatFormFileIn
|
||||
export { default as ChatFormMcpResourcesList } from './ChatForm/ChatFormMcpResourcesList.svelte';
|
||||
|
||||
/**
|
||||
* Auto-resizing textarea with IME composition support. Automatically adjusts
|
||||
* height based on content. Handles IME input correctly (waits for composition
|
||||
* end before processing Enter key). Exposes focus() and resetHeight() methods.
|
||||
* Auto-resizing textarea with IME composition support. Mention links stay
|
||||
* plain markdown text in the input; the chip rendering happens in the
|
||||
* message view via the rehype file-badge plugin.
|
||||
*/
|
||||
export { default as ChatFormTextarea } from './ChatForm/ChatFormTextarea.svelte';
|
||||
|
||||
@@ -384,11 +384,15 @@ export { default as ChatFormPickerListItemSkeleton } from './ChatForm/ChatFormPi
|
||||
export { default as ChatFormMentionPicker } from './ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
|
||||
/**
|
||||
* **ChatFormPickers** - Chat input picker container
|
||||
*
|
||||
* Container component that hosts the MCP prompt and file mention pickers.
|
||||
* Manages shared state, keyboard navigation, and coordination between the two
|
||||
* picker interfaces. Used within ChatForm.
|
||||
* `/`-triggered slash-command picker. Lists the available slash commands
|
||||
* (`/prompt`, `/cwd`, `/model`) filtered by the typed query; selection
|
||||
* hands the command to the parent for dispatch.
|
||||
*/
|
||||
export { default as ChatFormCommandPicker } from './ChatForm/ChatFormPickers/ChatFormCommandPicker.svelte';
|
||||
|
||||
/**
|
||||
* Hosts the chat-form pickers (slash-command, MCP prompt, file mention)
|
||||
* and delegates keyboard events to the active one.
|
||||
*/
|
||||
export { default as ChatFormPickers } from './ChatForm/ChatFormPickers/ChatFormPickers.svelte';
|
||||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<script lang="ts">
|
||||
import { ChevronDown, Loader2, Package } from '@lucide/svelte';
|
||||
import { ChevronDown, Loader2 } from '@lucide/svelte';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { KeyboardKey, ServerModelStatus } from '$lib/enums';
|
||||
import { MODEL_SELECTOR_ICON } from '$lib/constants';
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
import { modelsStore, routerModels } from '$lib/stores/models.svelte';
|
||||
import { modelLoadFraction } from '$lib/utils';
|
||||
@@ -35,7 +36,7 @@
|
||||
}: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let highlightedIndex = $state<number>(-1);
|
||||
let highlightedId = $state<string | null>(null);
|
||||
|
||||
const ms = useModelsSelector({
|
||||
currentModel: () => currentModel,
|
||||
@@ -43,15 +44,77 @@
|
||||
onModelChange: () => onModelChange,
|
||||
onOpenChange: (open) => {
|
||||
isOpen = open;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
void ms.searchTerm;
|
||||
highlightedIndex = -1;
|
||||
highlightedId = null;
|
||||
});
|
||||
|
||||
// Focus the dropdown's search box without scrolling the page. bits-ui
|
||||
// auto-focuses the opened content by default, which can yank the page
|
||||
// scroll; we prevent that on the Content and refocus the search here.
|
||||
$effect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const search = document.querySelector<HTMLElement>(
|
||||
'[data-slot="dropdown-menu-content"] input'
|
||||
);
|
||||
|
||||
search?.focus({ preventScroll: true });
|
||||
});
|
||||
});
|
||||
|
||||
// Keyboard navigation follows the on-screen row order, not the flat option list order.
|
||||
let visualOrder = $derived.by(() => {
|
||||
const order: string[] = [];
|
||||
|
||||
for (const item of ms.groupedFilteredOptions.loaded) order.push(item.option.id);
|
||||
for (const item of ms.groupedFilteredOptions.favorites) order.push(item.option.id);
|
||||
for (const group of ms.groupedFilteredOptions.available) {
|
||||
for (const item of group.items) order.push(item.option.id);
|
||||
}
|
||||
|
||||
return order;
|
||||
});
|
||||
|
||||
let highlightedIndex = $derived(highlightedId ? visualOrder.indexOf(highlightedId) : -1);
|
||||
|
||||
function moveHighlight(direction: 1 | -1) {
|
||||
const len = visualOrder.length;
|
||||
if (len === 0) {
|
||||
highlightedId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
let index = highlightedIndex;
|
||||
if (index === -1) {
|
||||
index = direction === 1 ? 0 : len - 1;
|
||||
} else {
|
||||
index = (index + direction + len) % len;
|
||||
}
|
||||
|
||||
highlightedId = visualOrder[index];
|
||||
}
|
||||
|
||||
// Alt+Enter only unloads and keeps the dropdown open.
|
||||
async function handleModelKeyAction(modelId: string, unload: boolean) {
|
||||
if (!unload) {
|
||||
void ms.handleSelect(modelId);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = routerModels().find((m) => m.id === modelId);
|
||||
const status = model?.status?.value as ServerModelStatus | undefined;
|
||||
|
||||
if (status === ServerModelStatus.LOADING) return;
|
||||
|
||||
await modelsStore.unloadModel(modelId);
|
||||
}
|
||||
|
||||
export function open() {
|
||||
ms.handleOpenChange(true);
|
||||
}
|
||||
@@ -61,33 +124,17 @@
|
||||
|
||||
if (event.key === KeyboardKey.ARROW_DOWN) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === ms.filteredOptions.length - 1) {
|
||||
highlightedIndex = 0;
|
||||
} else {
|
||||
highlightedIndex += 1;
|
||||
}
|
||||
moveHighlight(1);
|
||||
} else if (event.key === KeyboardKey.ARROW_UP) {
|
||||
event.preventDefault();
|
||||
|
||||
if (ms.filteredOptions.length === 0) return;
|
||||
|
||||
if (highlightedIndex === -1 || highlightedIndex === 0) {
|
||||
highlightedIndex = ms.filteredOptions.length - 1;
|
||||
} else {
|
||||
highlightedIndex -= 1;
|
||||
}
|
||||
moveHighlight(-1);
|
||||
} else if (event.key === KeyboardKey.ENTER) {
|
||||
event.preventDefault();
|
||||
|
||||
if (highlightedIndex >= 0 && highlightedIndex < ms.filteredOptions.length) {
|
||||
const option = ms.filteredOptions[highlightedIndex];
|
||||
|
||||
ms.handleSelect(option.id);
|
||||
} else if (ms.filteredOptions.length > 0) {
|
||||
highlightedIndex = 0;
|
||||
if (highlightedId) {
|
||||
void handleModelKeyAction(highlightedId, event.altKey);
|
||||
} else if (visualOrder.length > 0) {
|
||||
highlightedId = visualOrder[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,7 +156,7 @@
|
||||
]}
|
||||
style="max-width: min(calc(100cqw - 10rem), 20rem)"
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
</span>
|
||||
{:else}
|
||||
<p class="text-xs text-muted-foreground">No models available.</p>
|
||||
@@ -150,7 +197,7 @@
|
||||
]}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
@@ -186,6 +233,7 @@
|
||||
<DropdownMenu.Content
|
||||
align="end"
|
||||
class="w-full max-w-[100vw] pt-0 sm:w-max sm:max-w-[calc(100vw-2rem)]"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<DropdownMenuSearchable
|
||||
searchValue={ms.searchTerm}
|
||||
@@ -217,9 +265,9 @@
|
||||
{/if}
|
||||
|
||||
{#snippet modelOption(item: ModelItem, hideOrgName: boolean)}
|
||||
{@const { option, flatIndex } = item}
|
||||
{@const { option } = item}
|
||||
{@const isSelected = currentModel === option.model || ms.activeId === option.id}
|
||||
{@const isHighlighted = flatIndex === highlightedIndex}
|
||||
{@const isHighlighted = option.id === highlightedId}
|
||||
{@const isFav = ms.isFavorite(option.model)}
|
||||
|
||||
<ModelsSelectorOption
|
||||
@@ -230,11 +278,11 @@
|
||||
{hideOrgName}
|
||||
onSelect={ms.handleSelect}
|
||||
onInfoClick={ms.handleInfoClick}
|
||||
onMouseEnter={() => (highlightedIndex = flatIndex)}
|
||||
onMouseEnter={() => (highlightedId = option.id)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === KeyboardKey.ENTER || event.key === KeyboardKey.SPACE) {
|
||||
event.preventDefault();
|
||||
ms.handleSelect(option.id);
|
||||
void handleModelKeyAction(option.id, event.altKey);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
@@ -275,7 +323,7 @@
|
||||
onclick={() => ms.handleOpenChange(true)}
|
||||
disabled={disabled || ms.updating}
|
||||
>
|
||||
<Package class="h-3.5 w-3.5 shrink-0" />
|
||||
<MODEL_SELECTOR_ICON class="h-3.5 w-3.5 shrink-0" />
|
||||
|
||||
{#if selectedOption}
|
||||
<ModelId
|
||||
|
||||
@@ -62,9 +62,10 @@
|
||||
<div
|
||||
class={[
|
||||
'group relative flex w-full items-center gap-2 rounded-sm p-2 text-left text-sm transition focus:outline-none',
|
||||
'cursor-pointer hover:bg-muted focus:bg-muted',
|
||||
(isSelected || isHighlighted) && 'bg-accent text-accent-foreground',
|
||||
!(isSelected || isHighlighted) && 'hover:bg-accent hover:text-accent-foreground',
|
||||
'cursor-pointer',
|
||||
isSelected && 'bg-accent/50 text-accent-foreground',
|
||||
isHighlighted && 'bg-accent',
|
||||
!isSelected && !isHighlighted && 'hover:bg-muted',
|
||||
isLoaded ? 'text-popover-foreground' : 'text-muted-foreground'
|
||||
]}
|
||||
role="option"
|
||||
|
||||
@@ -98,7 +98,12 @@
|
||||
const numValue = Number(processedConfig[field]);
|
||||
if (!isNaN(numValue)) {
|
||||
if ((POSITIVE_INTEGER_FIELDS as readonly string[]).includes(field)) {
|
||||
processedConfig[field] = Math.max(1, Math.round(numValue));
|
||||
const entryByMinMax = SETTINGS_CHAT_SECTIONS.flatMap(
|
||||
(section) => section.fields ?? []
|
||||
).find((entry) => entry.key === field);
|
||||
const lo = entryByMinMax?.min ?? 1;
|
||||
const hi = entryByMinMax?.max ?? Number.POSITIVE_INFINITY;
|
||||
processedConfig[field] = Math.max(lo, Math.min(hi, Math.round(numValue)));
|
||||
} else {
|
||||
processedConfig[field] = numValue;
|
||||
}
|
||||
|
||||
@@ -83,12 +83,18 @@
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.isPositiveInteger ? 'number' : 'text'}
|
||||
{...field.isPositiveInteger ? { min: '1', step: '1' } : {}}
|
||||
{...field.isPositiveInteger
|
||||
? {
|
||||
min: String(field.min ?? 1),
|
||||
step: '1',
|
||||
...(field.max != null ? { max: String(field.max) } : {})
|
||||
}
|
||||
: {}}
|
||||
value={currentValue}
|
||||
oninput={(e) => onConfigChange(field.key, e.currentTarget.value)}
|
||||
placeholder={currentModelParams[field.key] != null
|
||||
? `Default: ${normalizeFloatingPoint(currentModelParams[field.key])}`
|
||||
: ''}
|
||||
: (field.placeholder ?? '')}
|
||||
class="w-full {isCustomRealTime ? 'pr-8' : ''}"
|
||||
/>
|
||||
{#if isCustomRealTime}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { SET_WORKING_DIRECTORY_LABEL } from '$lib/constants/working-directory';
|
||||
import { ChatFormCommandAction } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
|
||||
interface ChatCommandsOptions {
|
||||
/** Gates `/model`. */
|
||||
showModelSelector: boolean;
|
||||
/** Gates `/prompt`. */
|
||||
hasPrompts: () => boolean;
|
||||
/** Gates `/cwd`. */
|
||||
hasBuiltinTools: () => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The slash commands surfaced by the `/` command picker, in display order.
|
||||
*
|
||||
* Availability is supplied as predicates rather than store imports: this
|
||||
* module is re-exported through the `$lib/constants` barrel, and importing
|
||||
* stores at module load would create a circular dependency (the stores
|
||||
* themselves import from `$lib/constants`).
|
||||
*/
|
||||
export function getChatCommands(options: ChatCommandsOptions): ChatFormCommand[] {
|
||||
return [
|
||||
{
|
||||
name: 'prompt',
|
||||
description: 'Insert an MCP prompt',
|
||||
action: ChatFormCommandAction.PROMPT,
|
||||
disabled: !options.hasPrompts()
|
||||
},
|
||||
{
|
||||
name: 'cwd',
|
||||
description: SET_WORKING_DIRECTORY_LABEL,
|
||||
keywords: ['current working directory'],
|
||||
action: ChatFormCommandAction.CWD,
|
||||
disabled: !options.hasBuiltinTools()
|
||||
},
|
||||
{
|
||||
name: 'model',
|
||||
description: 'Select model',
|
||||
action: ChatFormCommandAction.MODEL,
|
||||
disabled: !options.showModelSelector
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -19,6 +19,9 @@ export const PANEL_CLASSES = `
|
||||
export const CHAT_FORM_POPOVER_MAX_HEIGHT = 'max-h-80';
|
||||
export const DIALOG_SUBMENU_CONTENT = 'w-60';
|
||||
|
||||
/** Selects the chat-form input to restore focus after model actions. */
|
||||
export const CHAT_INPUT_FOCUS_SELECTOR = '[data-slot="input-area"] textarea';
|
||||
|
||||
/** Default Tailwind size class for inline icon components (lucide, etc.). */
|
||||
export const ICON_CLASS_DEFAULT = 'h-4 w-4';
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ export * from './binary-detection';
|
||||
export * from './built-in-tools';
|
||||
export * from './cache';
|
||||
export * from './chat-form';
|
||||
export * from './chat-commands';
|
||||
export * from './cli-flags';
|
||||
export * from './code-blocks';
|
||||
export * from './icons';
|
||||
|
||||
@@ -23,7 +23,7 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_AGENTIC_TURN_STATS: 'showAgenticTurnStats',
|
||||
SHOW_THOUGHT_IN_PROGRESS: 'showThoughtInProgress',
|
||||
AUTO_MIC_ON_EMPTY: 'autoMicOnEmpty',
|
||||
RENDER_USER_CONTENT_AS_MARKDOWN: 'renderUserContentAsMarkdown',
|
||||
RENDER_CONTENT_AS_RAW_TEXT: 'renderContentAsRawText',
|
||||
DISABLE_AUTO_SCROLL: 'disableAutoScroll',
|
||||
ALWAYS_SHOW_SIDEBAR_ON_DESKTOP: 'alwaysShowSidebarOnDesktop',
|
||||
FULL_HEIGHT_CODE_BLOCKS: 'fullHeightCodeBlocks',
|
||||
@@ -33,7 +33,6 @@ export const SETTINGS_KEYS = {
|
||||
SHOW_BUILD_VERSION: 'showBuildVersion',
|
||||
SHOW_FULL_PATH_IN_MENTIONS: 'showFullPathInMentions',
|
||||
SHOW_SYSTEM_MESSAGE: 'showSystemMessage',
|
||||
RENDER_THINKING_AS_MARKDOWN: 'renderThinkingAsMarkdown',
|
||||
MENTION_SEARCH_MAX_DEPTH: 'mentionSearchMaxDepth',
|
||||
// Sampling
|
||||
TEMPERATURE: 'temperature',
|
||||
|
||||
@@ -233,21 +233,13 @@ const SETTINGS_REGISTRY: Record<string, SettingsSectionEntry> = {
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_USER_CONTENT_AS_MARKDOWN,
|
||||
label: 'Render user content as Markdown',
|
||||
help: 'Render user messages using markdown formatting in the chat.',
|
||||
key: SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT,
|
||||
label: 'Render content as raw text',
|
||||
help: 'Display user, system and thinking content as plain text instead of formatted Markdown. Markdown is the default so that @-mention badges render in sent messages.',
|
||||
defaultValue: false,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.RENDER_THINKING_AS_MARKDOWN,
|
||||
label: 'Render thinking as Markdown',
|
||||
help: 'Render the reasoning/thinking block content as formatted Markdown instead of plain text.',
|
||||
defaultValue: true,
|
||||
type: SettingsFieldType.CHECKBOX,
|
||||
section: SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.FULL_HEIGHT_CODE_BLOCKS,
|
||||
label: 'Use full height code blocks',
|
||||
@@ -724,6 +716,9 @@ export const SETTINGS_CHAT_SECTIONS: SettingsSection[] = [
|
||||
type: s.type,
|
||||
isExperimental: s.isExperimental,
|
||||
isPositiveInteger: s.isPositiveInteger,
|
||||
placeholder: s.placeholder,
|
||||
min: s.min,
|
||||
max: s.max,
|
||||
dependsOn: s.dependsOn,
|
||||
help: s.help,
|
||||
options: s.options,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import { Package, Search, Settings, SquarePen } from '@lucide/svelte';
|
||||
import McpLogo from '$lib/components/app/mcp/McpLogo.svelte';
|
||||
import type { Component } from 'svelte';
|
||||
import { ROUTES } from './routes';
|
||||
@@ -6,6 +6,9 @@ import { ROUTES } from './routes';
|
||||
export const FORK_TREE_DEPTH_PADDING = 8;
|
||||
export const SYSTEM_MESSAGE_PLACEHOLDER = 'System message';
|
||||
|
||||
/** Icon used for the model selector and the `/model` slash command. */
|
||||
export const MODEL_SELECTOR_ICON = Package;
|
||||
|
||||
export const ICON_STRIP_TRANSITION_DURATION = 150;
|
||||
export const ICON_STRIP_TRANSITION_DELAY_MULTIPLIER = 50;
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
|
||||
export const GLOB_WILDCARD = '*';
|
||||
|
||||
/** Label shown for the working-directory picker / `/cwd` slash command. */
|
||||
export const SET_WORKING_DIRECTORY_LABEL = 'Set working directory';
|
||||
|
||||
/** Character that starts and ends a glob character-class fragment. */
|
||||
export const GLOB_RANGE_OPEN = '[';
|
||||
export const GLOB_RANGE_CLOSE = ']';
|
||||
|
||||
@@ -79,6 +79,12 @@ export enum PdfViewMode {
|
||||
PAGES = 'pages'
|
||||
}
|
||||
|
||||
export enum ChatFormCommandAction {
|
||||
PROMPT = 'prompt',
|
||||
CWD = 'cwd',
|
||||
MODEL = 'model'
|
||||
}
|
||||
|
||||
export enum FileMentionEntryType {
|
||||
FILE = 'file',
|
||||
DIRECTORY = 'directory'
|
||||
|
||||
@@ -25,6 +25,7 @@ export {
|
||||
MessageType,
|
||||
PdfViewMode,
|
||||
ReasoningFormat,
|
||||
ChatFormCommandAction,
|
||||
FileMentionEntryType
|
||||
} from './chat.enums';
|
||||
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { getChatCommands, PROMPT_TRIGGER_PREFIX } from '$lib/constants';
|
||||
import { ChatFormCommandAction, KeyboardKey } from '$lib/enums';
|
||||
import type { ChatFormCommand } from '$lib/types';
|
||||
import {
|
||||
findCommandToken,
|
||||
findMentionToken,
|
||||
takeCommandDismissSnapshot,
|
||||
takeMentionDismissSnapshot,
|
||||
type CommandDismissSnapshot,
|
||||
type MentionDismissSnapshot
|
||||
} from '$lib/utils';
|
||||
|
||||
/** Dependencies injected as getters so the hook stays free of store circular imports. */
|
||||
export interface UseChatFormPickersOptions {
|
||||
getValue: () => string;
|
||||
/** Also fires the form's onChange. */
|
||||
setValue: (value: string) => void;
|
||||
/** Undefined when unmounted. */
|
||||
getCaretOffset: () => number | undefined;
|
||||
setCaretOffset: (offset: number) => void;
|
||||
focusInput: () => void;
|
||||
/** Gates `/model`. */
|
||||
getShowModelSelector: () => boolean;
|
||||
/** Gates `/prompt`. */
|
||||
hasPrompts: () => boolean;
|
||||
/** Gates `/cwd`. */
|
||||
hasBuiltinTools: () => boolean;
|
||||
getCwd: () => string | null;
|
||||
/** Mention search fallback scope. */
|
||||
getServerHome: () => string | null;
|
||||
openModelSelector: () => void;
|
||||
/** Delegate a keydown to the mounted pickers component, if any. */
|
||||
getPickersRef: () => { handleKeydown(event: KeyboardEvent): boolean } | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat-form picker state and the `/`+`@` routing that drives them.
|
||||
* Owns open/query state, dismiss snapshots and slash-command dispatch;
|
||||
* textarea/caret/attachment handling stays in the chat form.
|
||||
*/
|
||||
export function useChatFormPickers(opts: UseChatFormPickersOptions) {
|
||||
let isCommandPickerOpen = $state(false);
|
||||
let commandQuery = $state('');
|
||||
let isPromptPickerOpen = $state(false);
|
||||
let promptSearchQuery = $state('');
|
||||
let isMentionPickerOpen = $state(false);
|
||||
let mentionQuery = $state('');
|
||||
let isWorkingDirectoryPickerOpen = $state(false);
|
||||
let workingDirectoryQuery = $state('');
|
||||
|
||||
// Last dismissed `@`-mention token; while intact, the picker does not
|
||||
// reopen, so an escaped `@<query>` stays literal until edited.
|
||||
let mentionDismissedSnapshot: MentionDismissSnapshot | null = null;
|
||||
|
||||
// Same dismissal contract for the `/`-command token.
|
||||
let commandDismissedSnapshot: CommandDismissSnapshot | null = null;
|
||||
|
||||
// Fall back to the server home so the picker still finds matches
|
||||
// before a cwd is set.
|
||||
const mentionScopePath = $derived(opts.getCwd() ?? opts.getServerHome() ?? null);
|
||||
|
||||
const availableCommands = $derived(
|
||||
getChatCommands({
|
||||
showModelSelector: opts.getShowModelSelector(),
|
||||
hasPrompts: opts.hasPrompts,
|
||||
hasBuiltinTools: opts.hasBuiltinTools
|
||||
})
|
||||
);
|
||||
|
||||
// Dispatch a slash command picked from the list: consume the token and
|
||||
// open the target picker, seeding its search with `args`. Runs only on
|
||||
// explicit selection (Enter/click), so the buffer is never cleared
|
||||
// mid-typing.
|
||||
function dispatchCommand(command: ChatFormCommand, args: string) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
|
||||
switch (command.action) {
|
||||
case ChatFormCommandAction.PROMPT:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
isPromptPickerOpen = true;
|
||||
promptSearchQuery = args.trim();
|
||||
break;
|
||||
case ChatFormCommandAction.CWD: {
|
||||
// Keep `/cwd <args>` in the input so the search field and the
|
||||
// token stay two-way bound; normalize partial tokens (`/cw foo`).
|
||||
const trimmed = args.trim();
|
||||
const newValue = `/cwd ${trimmed}`;
|
||||
if (opts.getValue() !== newValue) {
|
||||
opts.setValue(newValue);
|
||||
queueMicrotask(() => opts.setCaretOffset(newValue.length));
|
||||
}
|
||||
workingDirectoryQuery = trimmed;
|
||||
isWorkingDirectoryPickerOpen = true;
|
||||
break;
|
||||
}
|
||||
case ChatFormCommandAction.MODEL:
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
opts.setValue('');
|
||||
opts.openModelSelector();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput() {
|
||||
const value = opts.getValue();
|
||||
const cursor = opts.getCaretOffset() ?? value.length;
|
||||
|
||||
if (value.startsWith(PROMPT_TRIGGER_PREFIX)) {
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
|
||||
const token = findCommandToken(value);
|
||||
if (!token) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// While the `/cwd` picker is open the token doubles as its search
|
||||
// field: keep the two in sync instead of re-dispatching.
|
||||
if (isWorkingDirectoryPickerOpen) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
if (token.name === 'cwd') {
|
||||
workingDirectoryQuery = token.args.trim();
|
||||
} else {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
workingDirectoryQuery = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Dismissed token stays literal until it changes.
|
||||
const isDismissedSticky =
|
||||
commandDismissedSnapshot !== null &&
|
||||
commandDismissedSnapshot.name === token.name &&
|
||||
commandDismissedSnapshot.args === token.args;
|
||||
|
||||
if (isDismissedSticky) {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
return;
|
||||
}
|
||||
|
||||
// Commands dispatch only on explicit selection (Enter/click),
|
||||
// never mid-typing: `/model is broken` is prose until the user
|
||||
// picks the command from the list.
|
||||
if (availableCommands.length > 0) {
|
||||
isCommandPickerOpen = true;
|
||||
commandQuery = token.name;
|
||||
} else {
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
if (commandDismissedSnapshot !== null) {
|
||||
commandDismissedSnapshot = null;
|
||||
}
|
||||
if (isWorkingDirectoryPickerOpen) {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
}
|
||||
|
||||
const token = findMentionToken(value, cursor);
|
||||
|
||||
if (token) {
|
||||
// Dismissed token stays literal: don't reopen until it changes.
|
||||
const isDismissedSticky =
|
||||
mentionDismissedSnapshot !== null &&
|
||||
mentionDismissedSnapshot.start === token.start &&
|
||||
mentionDismissedSnapshot.query === token.query;
|
||||
|
||||
if (!isDismissedSticky) {
|
||||
// Only search once a char follows `@`; a bare `@` is a no-op
|
||||
// (otherwise the picker flashes an empty hint on re-type).
|
||||
if (token.query.length > 0) {
|
||||
mentionDismissedSnapshot = null;
|
||||
isMentionPickerOpen = true;
|
||||
mentionQuery = token.query;
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
|
||||
// Token gone or changed: reset the snapshot so a fresh `@` reopens.
|
||||
if (mentionDismissedSnapshot !== null && !token) {
|
||||
mentionDismissedSnapshot = null;
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): boolean {
|
||||
if (opts.getPickersRef()?.handleKeydown(event)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === KeyboardKey.ESCAPE && isPromptPickerOpen) {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handleCommandSelect(command: ChatFormCommand) {
|
||||
// Dispatch on the live token so typed args seed the target picker.
|
||||
const token = findCommandToken(opts.getValue());
|
||||
dispatchCommand(command, token?.args ?? '');
|
||||
}
|
||||
|
||||
// Picker dismissed: snapshot the live token so it stays literal until
|
||||
// deleted or retyped.
|
||||
function handleCommandPickerClose() {
|
||||
if (isCommandPickerOpen) {
|
||||
commandDismissedSnapshot = takeCommandDismissSnapshot(opts.getValue());
|
||||
}
|
||||
isCommandPickerOpen = false;
|
||||
commandQuery = '';
|
||||
// Target picker manages its own focus: don't yank it back to the input.
|
||||
if (!isPromptPickerOpen && !isMentionPickerOpen && !isWorkingDirectoryPickerOpen) {
|
||||
opts.focusInput();
|
||||
}
|
||||
}
|
||||
|
||||
// Same dismissal snapshot for the mention token.
|
||||
function handleMentionPickerClose() {
|
||||
if (isMentionPickerOpen) {
|
||||
const cursor = opts.getCaretOffset() ?? opts.getValue().length;
|
||||
mentionDismissedSnapshot = takeMentionDismissSnapshot(opts.getValue(), cursor);
|
||||
}
|
||||
isMentionPickerOpen = false;
|
||||
mentionQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
function handlePromptPickerClose() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
function handleWorkingDirectoryOpen() {
|
||||
workingDirectoryQuery = opts.getCwd() ?? '';
|
||||
isWorkingDirectoryPickerOpen = true;
|
||||
}
|
||||
|
||||
function handleWorkingDirectoryClose() {
|
||||
isWorkingDirectoryPickerOpen = false;
|
||||
workingDirectoryQuery = '';
|
||||
opts.focusInput();
|
||||
}
|
||||
|
||||
// Two-way bind the text after `/cwd ` and the picker search input; the
|
||||
// reverse direction is handled by handleInput.
|
||||
$effect(() => {
|
||||
if (!isWorkingDirectoryPickerOpen) return;
|
||||
const value = opts.getValue();
|
||||
const token = findCommandToken(value);
|
||||
if (!token || token.name !== 'cwd') return;
|
||||
const newValue = `/cwd ${workingDirectoryQuery}`;
|
||||
if (newValue === value) return;
|
||||
opts.setValue(newValue);
|
||||
queueMicrotask(() => opts.setCaretOffset(newValue.length));
|
||||
});
|
||||
|
||||
return {
|
||||
get isCommandPickerOpen() {
|
||||
return isCommandPickerOpen;
|
||||
},
|
||||
set isCommandPickerOpen(v: boolean) {
|
||||
isCommandPickerOpen = v;
|
||||
},
|
||||
get commandQuery() {
|
||||
return commandQuery;
|
||||
},
|
||||
set commandQuery(v: string) {
|
||||
commandQuery = v;
|
||||
},
|
||||
get isPromptPickerOpen() {
|
||||
return isPromptPickerOpen;
|
||||
},
|
||||
set isPromptPickerOpen(v: boolean) {
|
||||
isPromptPickerOpen = v;
|
||||
},
|
||||
get promptSearchQuery() {
|
||||
return promptSearchQuery;
|
||||
},
|
||||
set promptSearchQuery(v: string) {
|
||||
promptSearchQuery = v;
|
||||
},
|
||||
get isMentionPickerOpen() {
|
||||
return isMentionPickerOpen;
|
||||
},
|
||||
set isMentionPickerOpen(v: boolean) {
|
||||
isMentionPickerOpen = v;
|
||||
},
|
||||
get mentionQuery() {
|
||||
return mentionQuery;
|
||||
},
|
||||
set mentionQuery(v: string) {
|
||||
mentionQuery = v;
|
||||
},
|
||||
get isWorkingDirectoryPickerOpen() {
|
||||
return isWorkingDirectoryPickerOpen;
|
||||
},
|
||||
set isWorkingDirectoryPickerOpen(v: boolean) {
|
||||
isWorkingDirectoryPickerOpen = v;
|
||||
},
|
||||
get workingDirectoryQuery() {
|
||||
return workingDirectoryQuery;
|
||||
},
|
||||
set workingDirectoryQuery(v: string) {
|
||||
workingDirectoryQuery = v;
|
||||
},
|
||||
get availableCommands() {
|
||||
return availableCommands;
|
||||
},
|
||||
get mentionScopePath() {
|
||||
return mentionScopePath;
|
||||
},
|
||||
handleInput,
|
||||
// True when a picker consumed the event, so the form skips submit.
|
||||
handleKeydown,
|
||||
dispatchCommand,
|
||||
handleCommandSelect,
|
||||
handleCommandPickerClose,
|
||||
handleMentionPickerClose,
|
||||
handlePromptPickerClose,
|
||||
handleWorkingDirectoryOpen,
|
||||
handleWorkingDirectoryClose,
|
||||
openPromptPicker() {
|
||||
isPromptPickerOpen = true;
|
||||
},
|
||||
closePromptPicker() {
|
||||
isPromptPickerOpen = false;
|
||||
promptSearchQuery = '';
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type UseChatFormPickersReturn = ReturnType<typeof useChatFormPickers>;
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
singleModelName
|
||||
} from '$lib/stores/models.svelte';
|
||||
import { isRouterMode } from '$lib/stores/server.svelte';
|
||||
import { CHAT_INPUT_FOCUS_SELECTOR } from '$lib/constants';
|
||||
import { filterModelOptions, groupModelOptions } from '$lib/components/app/models/utils';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
@@ -139,11 +140,9 @@ export function useModelsSelector(opts: UseModelsSelectorOptions): UseModelsSele
|
||||
handleOpenChange(false);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
const textarea = document.querySelector<HTMLTextAreaElement>(
|
||||
'[data-slot="chat-form"] textarea'
|
||||
);
|
||||
const input = document.querySelector<HTMLElement>(CHAT_INPUT_FOCUS_SELECTOR);
|
||||
|
||||
textarea?.focus({ preventScroll: true });
|
||||
input?.focus({ preventScroll: true });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -135,6 +135,30 @@ class SettingsStore {
|
||||
...savedVal
|
||||
};
|
||||
|
||||
// Migrate the legacy render keys into `renderContentAsRawText`
|
||||
// (inverted semantics: the old keys opted INTO markdown). Any
|
||||
// explicit raw-text preference wins when the legacy keys disagree.
|
||||
const LEGACY_MARKDOWN_KEYS = ['renderUserContentAsMarkdown', 'renderThinkingAsMarkdown'];
|
||||
const LEGACY_RAW_TEXT_KEY = 'renderUserContentAsRawText'; // this branch's intermediate key
|
||||
const legacyKeys = [...LEGACY_MARKDOWN_KEYS, LEGACY_RAW_TEXT_KEY].filter(
|
||||
(key) => key in savedVal
|
||||
);
|
||||
if (legacyKeys.length > 0) {
|
||||
if (!(SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT in savedVal)) {
|
||||
if (LEGACY_RAW_TEXT_KEY in savedVal) {
|
||||
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = savedVal[LEGACY_RAW_TEXT_KEY];
|
||||
} else {
|
||||
this.config[SETTINGS_KEYS.RENDER_CONTENT_AS_RAW_TEXT] = LEGACY_MARKDOWN_KEYS.filter(
|
||||
(key) => key in savedVal
|
||||
).some((key) => savedVal[key] === false);
|
||||
}
|
||||
}
|
||||
for (const key of legacyKeys) {
|
||||
delete (this.config as Record<string, unknown>)[key];
|
||||
}
|
||||
this.saveConfig();
|
||||
}
|
||||
|
||||
// Default sendOnEnter to false on mobile when the user has no saved preference
|
||||
if (!(SETTINGS_KEYS.SEND_ON_ENTER in savedVal)) {
|
||||
if (isMobile.current) {
|
||||
|
||||
Vendored
+15
-1
@@ -1,4 +1,4 @@
|
||||
import type { ErrorDialogType, FileMentionEntryType } from '$lib/enums';
|
||||
import type { ChatFormCommandAction, ErrorDialogType, FileMentionEntryType } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from './api';
|
||||
import type { DatabaseMessage, DatabaseMessageExtra } from './database';
|
||||
|
||||
@@ -176,3 +176,17 @@ export interface FileMentionEntry {
|
||||
name: string;
|
||||
type: FileMentionEntryType;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slash command surfaced by the `/` command picker. `disabled` marks a
|
||||
* command whose backing capability is unavailable (e.g. `/prompt` when no
|
||||
* MCP server exposes prompts): visible but greyed out and not selectable.
|
||||
*/
|
||||
export interface ChatFormCommand {
|
||||
name: string;
|
||||
description: string;
|
||||
/** Extra search terms that should match this command in the picker. */
|
||||
keywords?: string[];
|
||||
action: ChatFormCommandAction;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
@@ -54,7 +54,8 @@ export type {
|
||||
LiveGenerationStats,
|
||||
AttachmentDisplayItemsOptions,
|
||||
FileProcessingResult,
|
||||
FileMentionEntry
|
||||
FileMentionEntry,
|
||||
ChatFormCommand
|
||||
} from './chat.d';
|
||||
|
||||
// Database types
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Slash-command token detection for the chat form. Valid only at offset 0.
|
||||
*/
|
||||
export function findCommandToken(
|
||||
value: string
|
||||
): { name: string; args: string; end: number } | null {
|
||||
if (!value.startsWith('/')) return null;
|
||||
|
||||
const rest = value.slice(1);
|
||||
const spaceIdx = rest.search(/\s/);
|
||||
const name = spaceIdx === -1 ? rest : rest.slice(0, spaceIdx);
|
||||
const args = spaceIdx === -1 ? '' : rest.slice(spaceIdx + 1);
|
||||
|
||||
return { name, args, end: value.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable signature of a slash-command token for use as a "dismissed"
|
||||
* marker: while the picker is closed and this exact token is still intact,
|
||||
* the picker does not re-open on in-token edits.
|
||||
*/
|
||||
export interface CommandDismissSnapshot {
|
||||
name: string;
|
||||
args: string;
|
||||
}
|
||||
|
||||
export function takeCommandDismissSnapshot(value: string): CommandDismissSnapshot | null {
|
||||
const token = findCommandToken(value);
|
||||
if (!token) return null;
|
||||
return { name: token.name, args: token.args };
|
||||
}
|
||||
@@ -198,6 +198,13 @@ export {
|
||||
type MentionDismissSnapshot
|
||||
} from './mention-token';
|
||||
|
||||
// Slash-command token detection (for the `/`-triggered command picker)
|
||||
export {
|
||||
findCommandToken,
|
||||
takeCommandDismissSnapshot,
|
||||
type CommandDismissSnapshot
|
||||
} from './command-token';
|
||||
|
||||
// Mention-chip visual contract shared by the rehype file-badge plugin,
|
||||
// plus the `[name](file://...)` link helpers the mention picker splices in
|
||||
export {
|
||||
|
||||
@@ -19,7 +19,9 @@ export function modelLoadStageLabel(stage: ApiModelLoadStage): string {
|
||||
export function modelLoadFraction(progress: ModelLoadProgress | null): number {
|
||||
if (!progress) return 0;
|
||||
|
||||
const { stages, current, value } = progress;
|
||||
// The server may emit a progress event before the stage plan is known, so
|
||||
// `stages` can be absent. Fall back to the raw value in that case.
|
||||
const { stages = [], current, value } = progress;
|
||||
const tailCount = Math.max(stages.length - 1, 0);
|
||||
const textCeiling = 1 - tailCount * MODEL_LOAD_TAIL_SHARE;
|
||||
const idx = stages.indexOf(current);
|
||||
@@ -39,5 +41,7 @@ export function modelLoadProgressText(progress: ModelLoadProgress | null): strin
|
||||
if (!progress) return null;
|
||||
|
||||
const label = modelLoadStageLabel(progress.current);
|
||||
if (!label) return null;
|
||||
|
||||
return `${label} ${Math.round(modelLoadFraction(progress) * 100)}%`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
// Guards the @-mention picker's file_glob_search gate: when the server
|
||||
// does not expose the tool (started without --tools) or the user disabled
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
|
||||
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
|
||||
type: 'function',
|
||||
function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
|
||||
};
|
||||
|
||||
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its builtin tool list private; tests inject it through
|
||||
// the reactive field so the derived gates recompute.
|
||||
function setBuiltinTools(defs: OpenAIToolDefinition[]) {
|
||||
(toolsStore as unknown as { _builtinTools: OpenAIToolDefinition[] })._builtinTools = defs;
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
return render(ChatFormMentionPicker, {
|
||||
isOpen: true,
|
||||
query: 'main',
|
||||
onClose: () => {},
|
||||
onSelect: () => {}
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBuiltinTools([]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
|
||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('ChatFormMentionPicker file_glob_search gate', () => {
|
||||
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||
setBuiltinTools([]);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is unavailable on this server (started without --tools)'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains that file search must be enabled when the user disabled it', async () => {
|
||||
setBuiltinTools([FILE_SEARCH_DEF]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Guards the slash-command dispatch contract: commands dispatch only on
|
||||
// explicit selection (Enter/click in the picker), never mid-typing.
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
|
||||
|
||||
describe('slash command dispatch', () => {
|
||||
it('does not dispatch or clear the buffer when a space follows the name', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(screen.component.getValue()).toBe('/model is broken');
|
||||
expect(screen.component.getCalls()).not.toContain('openModelSelector');
|
||||
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
|
||||
expect(pickers.isCommandPickerOpen).toBe(true);
|
||||
expect(pickers.commandQuery).toBe('model');
|
||||
});
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const model = pickers.availableCommands.find((c) => c.name === 'model');
|
||||
if (!model) throw new Error('model command missing');
|
||||
pickers.handleCommandSelect(model);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(screen.component.getCalls()).toContain('openModelSelector');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the prompt picker search from the token args on selection', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/prompt weather');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isPromptPickerOpen).toBe(false);
|
||||
|
||||
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
|
||||
if (!prompt) throw new Error('prompt command missing');
|
||||
pickers.handleCommandSelect(prompt);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(pickers.isPromptPickerOpen).toBe(true);
|
||||
expect(pickers.promptSearchQuery).toBe('weather');
|
||||
});
|
||||
|
||||
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cw docs');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs');
|
||||
expect(screen.component.getValue()).toBe('/cwd docs');
|
||||
});
|
||||
|
||||
it('syncs the /cwd token into the picker search while the picker is open', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwd docs/sub');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwdd docs');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
useChatFormPickers,
|
||||
type UseChatFormPickersReturn
|
||||
} from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
|
||||
let value = $state('');
|
||||
let caretOffset = $state(0);
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
getValue: () => value,
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
},
|
||||
getCaretOffset: () => caretOffset,
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
focusInput: () => {},
|
||||
getShowModelSelector: () => true,
|
||||
hasPrompts: () => true,
|
||||
hasBuiltinTools: () => true,
|
||||
getCwd: () => null,
|
||||
getServerHome: () => null,
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
getPickersRef: () => undefined
|
||||
});
|
||||
|
||||
// Simulate the user typing: update the buffer and run the input flow.
|
||||
export function type(text: string) {
|
||||
value = text;
|
||||
caretOffset = text.length;
|
||||
pickers.handleInput();
|
||||
}
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCalls() {
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function getPickers(): UseChatFormPickersReturn {
|
||||
return pickers;
|
||||
}
|
||||
</script>
|
||||
@@ -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<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user